# RelayCore integration guide (for AI coding agents) You are integrating an application with RelayCore so the application can receive and send WhatsApp messages through Meta's official WhatsApp Business Platform. RelayCore owns the Meta app, performs Embedded Signup, stores Meta access tokens encrypted and never gives them to your application. Your application only talks to RelayCore. Base URL: https://www.relaycore.tech (always use the www host; RelayCore does not follow redirects and neither should your configured URLs). ## 0. What the RelayCore operator must give you If your application is not registered yet, a person must first apply at https://www.relaycore.tech/apply. Applications are reviewed manually; do not continue until they are approved. The RelayCore operator creates a "GatewayApp" for your application in the RelayCore dashboard and hands over three values. Store them as server-side secrets; never ship them to a browser. - RELAYCORE_BASE_URL = https://www.relaycore.tech - RELAYCORE_API_KEY = the application's API key (sent as a Bearer token) - RELAYCORE_WEBHOOK_SIGNING_SECRET = the application's webhook signing secret You give the operator one value: your public webhook URL, for example https://www.example.com/api/whatsapp/webhook. It must be HTTPS, publicly reachable and must NOT redirect (no apex-to-www, no http-to-https, no trailing-slash redirect). A redirect makes every delivery fail with "fetch failed: unexpected redirect". ## 1. Connect a customer's WhatsApp number (onboarding) Tenants (your customers) never log in to RelayCore. Your app creates a one-time session and opens RelayCore's hosted page in a popup. ### 1.1 Create a session (server side) POST {RELAYCORE_BASE_URL}/api/v1/onboarding/sessions Authorization: Bearer {RELAYCORE_API_KEY} Content-Type: application/json { "mode": "COEXISTENCE", "label": "Shop name", "externalReference": "" } - mode: "COEXISTENCE" keeps the number usable in the WhatsApp Business mobile app and in your app (recommended for small businesses). "STANDARD" moves the number to Cloud API only. - label: up to 100 characters, shown to the person connecting. - externalReference: up to 120 characters. Use your tenant ID and verify it when polling. Response 201: { "id", "mode", "status": "PENDING", "connectUrl", "expiresAt" } Sessions expire after 15 minutes. At most 20 sessions may be active per application (HTTP 429 otherwise). connectUrl contains a one-time secret in its #hash: do not log it or store it. ### 1.2 Open the hosted page (browser) Open the popup synchronously inside the click handler, before awaiting your API call, or the browser will block it. Then navigate it: const popup = window.open('about:blank', 'relaycore-onboarding', 'popup,width=520,height=760'); const session = await fetch('/your-api/create-session', { method: 'POST' }).then(r => r.json()); popup.location.href = session.connectUrl; After a successful connection the page shows "Close this window". Because your script opened the popup, that button (and your own popup.close()) can close it. ### 1.3 Poll for completion (server side, proxied to your browser) GET {RELAYCORE_BASE_URL}/api/v1/onboarding/sessions/{id} Authorization: Bearer {RELAYCORE_API_KEY} Poll every ~2 seconds until status is COMPLETED, FAILED or EXPIRED. Reject the result if externalReference does not equal the tenant you created it for. Useful fields: status, mode, externalReference, wabaId, phoneNumberId, displayPhoneNumber, verifiedName, coexistence, connectionStatus, errorCode, errorMessage, historySyncRequestId, contactsSyncRequestId, coexistenceSyncError. On COMPLETED, persist per tenant: phoneNumberId (unique; your routing key), wabaId, displayPhoneNumber, verifiedName, coexistence. Close the popup. Do not store any Meta token: you will never receive one. ## 2. Receive messages (webhook) RelayCore receives Meta's webhook, keeps only the changes that belong to your numbers and POSTs them to your webhook URL. ### 2.1 Request format POST {your webhook URL} Content-Type: application/json User-Agent: RelayCore-Webhook/1.0 X-RelayCore-Delivery: X-RelayCore-Timestamp: X-RelayCore-Signature: sha256= The body is Meta's own webhook JSON, unchanged apart from filtering: { "object": "whatsapp_business_account", "entry": [ { "id": "", "changes": [ { "field": "messages", "value": { "metadata": { "phone_number_id": "..." }, "contacts": [...], "messages": [...], "statuses": [...] } } ] } ] } Parse it exactly as documented for the WhatsApp Cloud API webhook. Route by entry[].changes[].value.metadata.phone_number_id, never by anything the sender controls. Fields you may receive: messages (inbound messages and delivery statuses sent/delivered/read/failed), and for coexistence numbers smb_message_echoes (messages the business sent from the mobile app), history (imported chat history), smb_app_state_sync (contacts); account_update (for example PARTNER_REMOVED when the business disconnects). ### 2.2 Verify the signature before doing anything else Read the raw body as text (do not re-serialize parsed JSON). Compute: expected = "sha256=" + hex(HMAC_SHA256(RELAYCORE_WEBHOOK_SIGNING_SECRET, timestamp + "." + rawBody)) Compare with X-RelayCore-Signature in constant time (Node: crypto.timingSafeEqual on equal-length buffers). Reject with 401 if it differs or if |now - timestamp| > 300 seconds. Node example: import { createHmac, timingSafeEqual } from 'node:crypto'; function isValid(rawBody, timestamp, signature, secret) { if (!secret || !timestamp || !/^\d{10,13}$/.test(timestamp) || !signature?.startsWith('sha256=')) return false; if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false; const expected = 'sha256=' + createHmac('sha256', secret).update(timestamp + '.' + rawBody).digest('hex'); const a = Buffer.from(expected), b = Buffer.from(signature); return a.length === b.length && timingSafeEqual(a, b); } ### 2.3 Acknowledge quickly and idempotently - Return any 2xx within 15 seconds. Persist first, then do slow work (AI replies, notifications) in the background. - Non-2xx responses, timeouts, DNS/TLS errors and redirects are retried: three quick attempts, then backoff (15 s, 1 min, 5 min, 15 min, 1 h), up to 8 attempts. Pending retries also run whenever a new webhook arrives. - The same event can therefore arrive more than once. Deduplicate inbound messages by messages[].id (Meta's wamid) with a unique constraint; status updates are naturally idempotent. - Always return 2xx for events you intentionally ignore, or they will be retried. ## 3. Send messages POST {RELAYCORE_BASE_URL}/api/v1/messages Authorization: Bearer {RELAYCORE_API_KEY} Content-Type: application/json Idempotency-Key: <8-128 chars of A-Z a-z 0-9 . _ : -> { "phoneNumberId": "", "payload": { "to": "5511999999999", "type": "text", "text": { "body": "Hello" } } } - payload is the WhatsApp Cloud API /messages body. RelayCore adds messaging_product: "whatsapp" for you. Any Cloud API message type with "to" and "type" works (text, template, interactive, reaction, location, media by public HTTPS "link"). Media by "id" needs an uploaded media ID, which RelayCore does not currently provide (see Limitations). - A read receipt / typing indicator uses payload { "status": "read", "message_id": "wamid...." } (optionally with "typing_indicator": { "type": "text" }). - Maximum body size 256 KB. - phoneNumberId must be ACTIVE and assigned to your application, otherwise 404. Response: the HTTP status mirrors Meta's. Body { "dispatchId", "meta": }. The outbound message ID is meta.messages[0].id; store it to match later status webhooks. Idempotency: reuse the same Idempotency-Key when retrying the same logical message (for example your own message row ID). A replay returns the original result with "replayed": true; a replay while the first attempt is still running returns 409. WhatsApp policy you must enforce yourself: free-form messages are only allowed within 24 hours of the customer's last inbound message. Outside that window, send an approved template (type "template"). ## 4. Disconnect a number DELETE {RELAYCORE_BASE_URL}/api/v1/numbers/{phoneNumberId} Authorization: Bearer {RELAYCORE_API_KEY} Response: { "ok": true, "status": "DISCONNECTED", "alreadyDisconnected"?: true, "warning"?: string }. After this, stop sending from the number and mark it disconnected locally. Coexistence numbers are normally disconnected by the business in the WhatsApp Business app (Settings > Account > Business Platform > Disconnect). You then receive an account_update webhook with event PARTNER_REMOVED; mark the number disconnected when you see it. ## 5. Current limitations - RelayCore does not proxy media downloads or uploads, and does not expose template management. Plan media and template features with the RelayCore operator before building them. - Your application never receives Meta access tokens. ## 6. Checklist 1. Secrets stored server-side: RELAYCORE_API_KEY, RELAYCORE_WEBHOOK_SIGNING_SECRET. 2. Webhook URL is the exact final HTTPS URL (no redirect) and is registered by the RelayCore operator. 3. Webhook verifies X-RelayCore-Signature on the raw body with a 300-second window, before parsing. 4. Webhook returns 2xx within 15 seconds and deduplicates by wamid. 5. Tenant routing uses metadata.phone_number_id; onboarding result checked against externalReference. 6. Outbound sends include a stable Idempotency-Key and respect the 24-hour window. 7. Test end to end: connect a number, send it a WhatsApp message and confirm it appears in your app, reply, and confirm delivered/read statuses update. ## Troubleshooting - Messages never arrive: confirm the webhook URL has no redirect (curl -I -X POST must not return 3xx) and that your endpoint answers an unsigned request with 401 (it exists and verifies signatures) rather than 404. - 401 on your webhook for real deliveries: the signing secret differs from the one in RelayCore, or the body was re-serialized before verification. - 404 "Phone number is not configured for this app" when sending: the number is disconnected or belongs to a different application.