Webhooks
Send data to your external systems when events happen in your workspace.
Setting Up Webhooks
Webhooks are configured as Actions:
- Go to Actions
- Create a new action with trigger (e.g., "Data Record Created")
- Select Send Webhook as the action type
- Enter your HTTPS endpoint URL — Gravity Rail rejects
http://URLs in production because webhook payloads can include PHI (member name, email, phone, DataRecord field values), and HIPAA 45 CFR 164.312(e)(2)(ii) requires that PHI be encrypted in transit.http://URLs are still permitted in development environments for local testing againstlocalhost/webhook.site/host.docker.internal.
Security
Request signing
When an event rule's webhook action has a signing secret configured, Gravity Rail sends an HMAC-SHA256 signature on every delivery:
- Header:
X-Webhook-Signature - Format:
t={unix_timestamp},v1={hex_digest} - Signed message: the ASCII string
{timestamp}.concatenated with the raw JSON body bytes (exactly as sent on the wire) - Secret: auto-generated per event rule on creation; view or rotate it in the rule's webhook action settings (not under API Keys)
Reject requests when the timestamp is more than five minutes old (replay protection). Compare signatures with a constant-time function.
Python:
import hashlib
import hmac
import time
def verify_webhook(body: bytes, header: str, secret: str, tolerance_sec: int = 300) -> bool:
parts = dict(p.split("=", 1) for p in header.split(","))
timestamp, signature = int(parts["t"]), parts["v1"]
if abs(time.time() - timestamp) > tolerance_sec:
return False
message = f"{timestamp}.".encode() + body
expected = hmac.new(secret.encode(), message, hashlib.sha256).hexdigest()
return hmac.compare_digest(signature.encode(), expected.encode())
Node.js:
const crypto = require('crypto');
function verifyWebhook(body, header, secret, toleranceSec = 300) {
const parts = Object.fromEntries(header.split(',').map((p) => p.split('=')));
const timestamp = parseInt(parts.t, 10);
const signature = parts.v1;
if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSec) return false;
const message = Buffer.concat([Buffer.from(`${timestamp}.`), body]);
const expected = crypto.createHmac('sha256', secret).update(message).digest('hex');
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
Payloads are JSON in the request body. Signing is optional per rule; rules without a secret still deliver over HTTPS in production.
Payload Shapes
Every payload is a JSON object with two top-level envelope fields, present on all shapes:
event— the event type (e.g.data_record:created,member:updated,call:completed).eventId— a unique delivery identifier (UUID string), generated once when the event fires. Retries of a failed delivery carry the sameeventId, so use it as your dedup/idempotency key.
Beyond that, the body takes one of three shapes depending on the event family. All payloads carry eventRule and workspace blocks; the rest of the fields vary by family as described below.
A context block is appended to any payload when the triggering rule provides extra context (for example, label changes on a member update). Its contents depend on the event — treat it as an optional, event-specific bag.
Data record events
Sent for data_record:created and data_record:updated. This is the most detailed shape — it carries the full record, its data type, and the owning member.
{
"event": "data_record:created",
"eventId": "1c9f6a2e-7d41-4f4b-9a53-8b1c2f6d0e77",
"dataType": {
"uuid": "5f3c…",
"type": "Contact Form",
"slug": "contact_form"
},
"member": {
"id": 123,
"name": "Jane Smith",
"email": "jane@example.com",
"phone": "+15551234567",
"externalId": "crm-9921",
"labels": ["vip", "active"],
"createdAt": "2026-01-10T09:00:00+00:00",
"updatedAt": "2026-06-16T14:30:00+00:00"
},
"dataRecord": {
"id": 4567,
"externalId": null,
"fields": {
"message": "Hello!",
"priority": "high"
},
"createdAt": "2026-06-16T14:30:00+00:00",
"updatedAt": "2026-06-16T14:30:00+00:00"
},
"eventRule": {
"id": 88,
"uuid": "a1b2…",
"name": "Notify CRM on new contact"
},
"author": {
"id": 5,
"accountUuid": "c4d5…",
"name": "Acme Operator",
"email": "ops@acme.example"
},
"workspace": {
"uuid": "9e8d…",
"name": "Acme Health"
}
}
dataRecord.fieldsmirrors the form's submitted values keyed by field slug.memberis the record owner;authoris the member who triggered the event (they differ when an operator edits a record on a member's behalf).
Member events
Sent for member:updated. Carries the member and, when relevant, a context block describing what changed.
{
"event": "member:updated",
"eventId": "5b7e9d13-2a86-49c0-b7f2-64a0c9e3d215",
"member": {
"id": 123,
"accountUuid": "c4d5…",
"name": "Jane Smith",
"description": null,
"phone": "+15551234567",
"email": "jane@example.com",
"externalId": "crm-9921",
"labels": ["vip", "active"],
"role": "patient",
"createdAt": "2026-01-10T09:00:00+00:00",
"updatedAt": "2026-06-16T14:30:00+00:00"
},
"eventRule": {
"id": 88,
"uuid": "a1b2…",
"name": "Sync label changes"
},
"author": {
"id": 5,
"accountUuid": "c4d5…",
"name": "Acme Operator",
"email": "ops@acme.example"
},
"workspace": {
"uuid": "9e8d…",
"name": "Acme Health"
},
"context": {
"labelChanges": {
"addedLabels": ["vip"],
"removedLabels": []
}
}
}
Other events (chat- and call-scoped)
Every other event type — including call:completed, call:started, call:summary, chat:created, workflow:user_message, calendar and journey events — uses a leaner generic shape. It always carries the envelope fields (event, eventId), eventRule, and workspace, and adds a member block when a member is in context. The member block carries externalId and phone so you can reconcile the event to your own records (externalId primary, phone fallback) — the same fields the member and data record families already deliver.
When the triggering event is scoped to a chat (such as an end-of-call call:completed), the payload also carries a chat block with the chat's identifiers. Call-lifecycle events (call:started, call:completed, call:summary) add a call block with the call's metadata:
{
"event": "call:completed",
"eventId": "9d3f1c88-6b0a-45e2-9f41-c7a2b5d80e63",
"eventRule": {
"id": 88,
"uuid": "a1b2…",
"name": "Export call to data warehouse"
},
"workspace": {
"uuid": "9e8d…",
"name": "Acme Health"
},
"member": {
"id": 123,
"accountUuid": "c4d5…",
"name": "Jane Smith",
"email": "jane@example.com",
"phone": "+15551234567",
"externalId": "crm-9921"
},
"chat": {
"id": 73219,
"uuid": "f7a9…"
},
"call": {
"id": 5512,
"providerCallId": "CAe0f3…",
"status": "completed",
"direction": "outbound",
"durationSeconds": 62,
"startedAt": "2026-06-16T14:29:00+00:00",
"endedAt": "2026-06-16T14:30:02+00:00",
"answeredBy": "human"
},
"scheduledCallbacks": [
{
"uuid": "b2c3…",
"scheduledTime": "2026-06-17T17:00:00+00:00",
"targetChannel": "phone-voice",
"status": "pending",
"reason": "follow up on appointment",
"scheduledBy": "agent"
}
]
}
- The
chatblock appears only when a chat is in context. Use its integeridto fetch the call's audio and transcript over your own authenticated request — see Fetching call audio and transcript. - The recording URL is never included in the payload. Recordings are PHI, so Gravity Rail delivers only the identifier; you fetch the media with your own credentials.
call block fields (only on call:started, call:completed, call:summary, and only when the chat has a phone call):
providerCallId— the telephony provider's call SID (TwilioCAxxxx…).nullwhen the provider never assigned one (e.g. a blocked or never-connected dial).status— Twilio-style call status:completed,busy,no-answer,failed, orcanceledfor terminal calls;in-progressoncall:started;initiatedwhen the dial was created but never connected. Treat this field as nullable —nullmeans the call state could not be determined.direction—inboundoroutbound.durationSeconds— connected duration;0for calls that ended without connecting (busy, no-answer);nullwhile the call is live.startedAt/endedAt— ISO 8601 timestamps;nullwhen not yet reached.answeredBy— Twilio answering machine detection result (human,machine_end_beep, …) ornullwhen AMD did not run.
The caller's phone number is deliberately not included in the call block — reconcile via member.phone / member.externalId instead.
call:summary payloads carry the summary. When the post-call summary event fires, the payload includes a top-level summary string (and summaryUpdatedAt timestamp). This is by design: subscribing to an event named "summary" is an explicit opt-in to receiving that content, consistent with data_record:* payloads carrying field values by default. call:started and call:completed never include the summary.
scheduledCallbacks appears on call:completed and call:summary payloads when the AI agent scheduled one or more follow-up callbacks during the call (via its scheduling tool) that are still open (pending or in_progress), ordered by scheduledTime. scheduledBy is currently always "agent" — AI-initiated callbacks surface here, while human callback requests are workspace form data that reaches you through data_record:* events. The key is omitted entirely when there are no open callbacks.
Note that reason is the member's own words for why they asked to be called back — treat it as PHI like the rest of the payload: store it encrypted at rest, keep it out of your logs, and return it only to authorized users.
Fetching call audio and transcript
A call:completed webhook gives you the chat.id, not the media itself. Both the audio and the transcript are keyed by that integer chat.id and require an API key with the chats:read scope.
Audio — list the recorded chunks, then request a short-lived presigned download URL for each:
# List recorded audio chunks for the chat
curl -H "Authorization: Bearer $GR_API_KEY" \
"https://api.gravityrail.com/api/v2/w/$WORKSPACE_UUID/recordings/chat/$CHAT_ID"
# Get a presigned download URL for a chunk's s3_key (from the list above)
curl -H "Authorization: Bearer $GR_API_KEY" \
"https://api.gravityrail.com/api/v2/w/$WORKSPACE_UUID/recordings/chat/$CHAT_ID/download?s3_key=$S3_KEY"
Transcript — read the chat and its messages:
curl -H "Authorization: Bearer $GR_API_KEY" \
"https://api.gravityrail.com/api/v2/w/$WORKSPACE_UUID/chats/$CHAT_ID"
This keeps PHI off the webhook channel: the payload carries only an opaque identifier, and the audio/transcript travel over authenticated, access-controlled requests you initiate.
Testing
- Use webhook.site for development
- Create a test action pointing to your test URL
- Trigger the event
- Verify the payload arrives and the
X-Webhook-Signatureheader validates (if signing is enabled)
Tips
- HTTPS is enforced in production - The API rejects
http://URLs at create/update time with a 400 error so PHI in webhook payloads is never transmitted unencrypted (HIPAA 45 CFR 164.312(e)(2)(ii)). Existing rules withhttp://URLs are also blocked at delivery time and surfaced aswebhook_url_blockedin the rule UI — update the URL tohttps://to resume delivery. - Handle retries - Implement idempotent handlers keyed on
eventId(retries of the same delivery carry the sameeventId) - Check logs - Failed webhooks appear in action logs
Related
- Actions — Configure webhook triggers and conditions
- CEL Expressions — Write conditions to control when webhooks fire
- Template Variables — Dynamic variables available in webhook payloads