> For clean Markdown of any page, append .md to the page URL. > For a complete documentation index, see https://docs.pinnacle.sh/v-2/methods/process/llms.txt. > For AI client integration (Claude Code, Cursor, etc.), connect to the MCP server at https://docs.pinnacle.sh/_mcp/server. # Receiving Messages and User Events > Process and validate incoming webhook requests When your webhook receives inbound messages, user events, form submissions, or campaign status updates from Pinnacle, the raw request body lacks type information. The `process` method validates incoming events by checking the signing secret and transforms the request into a fully typed [`MessageEvent`](/webhooks/message-events), [`UserEvent`](/webhooks/user-events), [`FormSubmissionEvent`](/webhooks/form-events), or [`CampaignStatusEvent`](/webhooks/campaign-events) object. ## Parameters #### TypeScript | Parameter | Type | Description | | --------- | ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | `req` | `Request \| ExpressLikeRequest` | Standard Fetch API `Request` object or Express-like request that must contain both a `headers` and `body` field. | | `secret` | `string` (optional) | Signing secret for webhook validation. If not provided, uses `PINNACLE_SIGNING_SECRET` environment variable. Throws an error if neither is provided. | #### Python | Parameter | Type | Description | | --------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------- | | `req` | `PinnacleRequest` | `dict` with:   - `headers`: `Dict[str, Any]`   - `body`: `str` (JSON string) | | `secret` | `str` (optional) | Signing secret for webhook validation. If not provided, uses `PINNACLE_SIGNING_SECRET`. Raises if neither is provided. | #### Ruby | Parameter | Type | Description | | --------- | ------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | `request` | `Hash` | Hash with:   - `:headers`: `Hash` containing request headers   - `:body`: `String` (JSON string) or `Hash` (parsed JSON) | | `secret:` | `String` (optional) | Signing secret for webhook validation. If not provided, uses `PINNACLE_SIGNING_SECRET` env var. Raises if neither is provided. | ## Returns One of the following objects (discriminated by the `type` field): * [`MessageEvent`](/webhooks/message-events): Inbound message or message status update (`MESSAGE.RECEIVED`, `MESSAGE.STATUS`). * [`UserEvent`](/webhooks/user-events): User event such as when a user started typing (`USER.TYPING`). * [`FormSubmissionEvent`](/webhooks/form-events): A recipient completed a hosted form (`FORM.SUBMISSION`). * [`CampaignStatusEvent`](/webhooks/campaign-events): Per-carrier launch or verification status changed for an RCS campaign (`CAMPAIGN.STATUS`). ## Errors | Error | Description | | ------------------- | -------------------------------- | | `UnauthorizedError` | Webhook secret validation failed | | `BadRequestError` | Request body validation failed | ## Example Implementation #### TypeScript ```typescript import express from 'express'; import { PinnacleClient, Pinnacle } from 'rcs-js'; const app = express(); const client = new PinnacleClient({ apiKey: PINNACLE_API_KEY }); app.post('/webhook', express.json(), async (req, res) => { try { // Process and validate the webhook. Returns one of: // Pinnacle.MessageEvent | Pinnacle.UserEvent | Pinnacle.FormSubmissionEvent | Pinnacle.CampaignStatusEvent const event = await client.messages.process(req); switch (event.type) { case "MESSAGE.RECEIVED": case "MESSAGE.STATUS": console.log(`${event.direction} ${event.type}:`, event.message); break; case "USER.TYPING": console.log("user typing in conversation", event.conversation.id); break; case "FORM.SUBMISSION": console.log("form", event.form.id, "submitted:", event.submission.data); break; case "CAMPAIGN.STATUS": // Per-carrier launch + verification status for an RCS campaign. console.log( "campaign status for", event.agent.id, "carriers:", event.carrierLaunches.carriers, "verification:", event.carrierLaunches.verification, ); break; } res.status(200).json({ status: 'processed' }); } catch (error) { throw error } }); ``` #### Python ```python from fastapi import FastAPI, Request, HTTPException from rcs import ( Pinnacle, MessageEvent, UserEvent, FormSubmissionEvent, CampaignStatusEvent, ) import os from dotenv import load_dotenv load_dotenv() app = FastAPI(title="RCS Webhook Server") # Initialize Pinnacle client client = Pinnacle(api_key=os.environ.get("PINNACLE_API_KEY")) @app.post("/webhook") async def webhook_handler(request: Request): """Handle incoming RCS webhooks.""" try: # Process and validate the webhook. Returns a fully typed event. event: ( MessageEvent | UserEvent | FormSubmissionEvent | CampaignStatusEvent ) = client.messages.process( { "headers": dict(request.headers), "body": await request.body(), } ) if isinstance(event, FormSubmissionEvent): print(f"form {event.form.id} submitted: {event.submission.data}") elif isinstance(event, CampaignStatusEvent): # Per-carrier launch + verification status for an RCS campaign. print( f"campaign status for {event.agent.id} -- " f"carriers: {event.carrier_launches.carriers}, " f"verification: {event.carrier_launches.verification}" ) elif isinstance(event, UserEvent): print(f"user typing in conversation {event.conversation.id}") else: # MessageEvent — MESSAGE.RECEIVED or MESSAGE.STATUS print(f"{event.direction} {event.type}: {event.message}") return {"status": "success", "event_type": type(event).__name__} except Exception as error: raise HTTPException(status_code=400, detail=str(error)) if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=8000) ``` #### Ruby ```ruby require 'sinatra' require 'rcs' # Initialize Pinnacle client client = Pinnacle::Client.new(api_key: ENV['PINNACLE_API_KEY']) post '/webhook' do content_type :json begin # Process and validate the webhook. Returns a fully typed event: # Pinnacle::Types::MessageEvent | UserEvent | FormSubmissionEvent | CampaignStatusEvent event = client.messages.process( { headers: request.env.select { |k, _| k.start_with?('HTTP_') } .transform_keys { |k| k.sub('HTTP_', '').split('_').map(&:capitalize).join('-') }, body: request.body.read } ) case event when Pinnacle::Types::FormSubmissionEvent puts "form #{event.form.id} submitted: #{event.submission.data}" when Pinnacle::Types::CampaignStatusEvent # Per-carrier launch + verification status for an RCS campaign. puts "campaign status for #{event.agent.id} -- " \ "carriers: #{event.carrier_launches.carriers}, " \ "verification: #{event.carrier_launches.verification}" when Pinnacle::Types::UserEvent puts "user typing in conversation #{event.conversation.id}" else # MessageEvent — MESSAGE.RECEIVED or MESSAGE.STATUS puts "#{event.direction} #{event.type}: #{event.message}" end { status: 'success', event_type: event.class.name }.to_json rescue Pinnacle::Errors::UnauthorizedError => e status 401 { error: e.message }.to_json rescue Pinnacle::Errors::ClientError => e status 400 { error: e.message }.to_json end end ``` > One API for RCS, iMessage, MMS, and SMS. Build, test, and scale every channel — send your first message in minutes, not weeks.