Skip to main content

Webhooks

Send data to your external systems when events happen in your workspace.

Setting Up Webhooks

Webhooks are configured as Actions:

  1. Go to Actions
  2. Create a new action with trigger (e.g., "Data Record Created")
  3. Select Send Webhook as the action type
  4. 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 against localhost / 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 same eventId, 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.fields mirrors the form's submitted values keyed by field slug.
  • member is the record owner; author is 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 chat block appears only when a chat is in context. Use its integer id to 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 (Twilio CAxxxx…). null when the provider never assigned one (e.g. a blocked or never-connected dial).
  • status — Twilio-style call status: completed, busy, no-answer, failed, or canceled for terminal calls; in-progress on call:started; initiated when the dial was created but never connected. Treat this field as nullable — null means the call state could not be determined.
  • directioninbound or outbound.
  • durationSeconds — connected duration; 0 for calls that ended without connecting (busy, no-answer); null while the call is live.
  • startedAt / endedAt — ISO 8601 timestamps; null when not yet reached.
  • answeredBy — Twilio answering machine detection result (human, machine_end_beep, …) or null when 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

  1. Use webhook.site for development
  2. Create a test action pointing to your test URL
  3. Trigger the event
  4. Verify the payload arrives and the X-Webhook-Signature header 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 with http:// URLs are also blocked at delivery time and surfaced as webhook_url_blocked in the rule UI — update the URL to https:// to resume delivery.
  • Handle retries - Implement idempotent handlers keyed on eventId (retries of the same delivery carry the same eventId)
  • Check logs - Failed webhooks appear in action logs