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") — see the event catalog for every available trigger and the payload it delivers
  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.

Delivery

MethodPOST
Content-Typeapplication/json
BodyJSON with alphabetically sorted keys, UTF-8
Signature headerX-Webhook-Signature (only when the rule has a signing secret)
Custom headersNot supported — a delivery sends Content-Type and, when signing is enabled, X-Webhook-Signature. Nothing else
Timeout30 seconds to first response
Successany 2xx
Retriesup to 5 attempts total, exponential backoff from 10 seconds (10s, 20s, 40s, 80s), capped at 5 minutes

What is and is not retried:

  • Retried: 5xx responses, timeouts, and transient connection errors.
  • Not retried: any 4xx. A 4xx is treated as a permanent rejection — the delivery is logged as failed and the rule is flagged, but nothing is resent. Return 2xx as soon as you have durably accepted the payload and do your own processing asynchronously; do not return 4xx for "try me later".
  • Also not retried: a URL that fails the safety check, an unresolvable hostname, or a connection refused at the destination.

Every attempt is logged. Each delivery attempt writes a row you can read back over the Webhook Logs API or in Settings → Webhooks, carrying the URL, event type, HTTP status, success flag, error message, and a dispatchKey. dispatchKey is the same value as the payload's eventId — that is the handle for correlating your side with ours. Logs are pruned to the most recent 1000 rows per workspace.

Event catalog

Every event type that can trigger a webhook, what fires it, and which blocks the delivered payload carries. A = always, S = sometimes, — = never.

The Scope column is what an event rule attaches to — it determines which rules fire. Workspace means every active rule of that type in the workspace fires; anything else means the rule is bound to that specific object.

Data record

EventScopeShapememberchatOther blocks
data_record:createdForm (DataType)Data recordAdataType A, dataRecord A, author A
data_record:updatedForm (DataType)Data recordAdataType A, dataRecord A, author A, context S
data_record:changedForm (DataType)Data recordAdataType A, dataRecord A, author A, operation A, context S

member here is the record owner; author is who triggered the change. context is present when the update actually changed at least one field — an update that changed nothing carries no context key.

Member and workspace membership

EventScopeShapememberchatOther blocks
member:updatedWorkspaceMemberAauthor A, context A
workspace:member_joinedWorkspaceLeanA
workspace:member_leftWorkspaceLeanA

workspace:member_joined and workspace:member_left do not fire yet. They can be selected and saved, and a rule using them is accepted, but no code path currently emits them, so no webhook is ever delivered. Do not build against them. To detect new members today, poll the Members API or use a data_record:* rule on your intake form.

Chat and messages

EventScopeShapememberchatOther blocks
chat:createdWorkspaceLeanAA
chat:summaryWorkspaceLeanAAsummary S, summaryUpdatedAt S, call S, scheduledCallbacks S
workflow:user_messageWorkflow revisionLeanAA
workflow:assistant_messageWorkflow revisionLeanAA

chat:summary is detailed under Chat summaries.

Workflow and task lifecycle

EventScopeShapememberchatOther blocks
workflow:executeWorkflow revisionLeanAA
task:executeTaskLeanAA
task:enterTaskLeanAA
task:commandTaskLeanAA
task:exitTaskLeanAA

workflow:execute fires when an Assignment starts, whether or not the workflow has tasks — use it rather than task:execute for "workflow began".

task:enter / task:command / task:exit fire only for workflows whose routing mode is strict; open-world workflows use task:execute only. Rules for the three strict phases are authorable in the task editor's rules tab (On Enter / Commands / On Exit sections) as well as through the API; strict action parameters currently use the JSON editor rather than bespoke forms.

The payload has no task or workflow block. Identify the task or workflow from your own rule — you know which task the rule is attached to, because you attached it — and use the chat block to fetch state over the API.

Calendar

EventScopeShapememberchatOther blocks
calendar_event:createdCalendar or Event TypeLeanA
calendar_event:updatedCalendar or Event TypeLeanA
calendar_event:cancelledCalendar or Event TypeLeanA
calendar_event:startingCalendar or Event TypeLeanA
calendar_event:endedCalendar or Event TypeLeanA

The payload carries no calendar_event block, no chat block, and no context. It tells you that a booking changed, not what changed or when the booking is. Read the booking from the Calendar Events API on receipt. If you need the appointment time in the payload itself, mirror it into a Form and subscribe to data_record:changed instead.

member is not the attendee. It is the event's creator, falling back to the calendar's owner — so for an operator-created or provider-synced booking it is the staff member or calendar owner, not the person the appointment is for. Read the attendee from the booking.

Journeys

EventScopeShapememberchatOther blocks
journey:enrolledJourneyLeanA
journey:completedJourneyLeanA
journey_step:executeJourney stepLeanA

Same caveat as calendar: there is no journey or journeyStep block. Because a journey rule is bound to one specific Journey (and journey_step:execute to one specific step), your rule is the identification — give each rule a distinct URL or a distinct eventRule.name if you need to tell them apart.

Voice calls

EventScopeShapememberchatOther blocks
call:startedWorkspaceLeanAAcall S
call:completedWorkspaceLeanAAcall S, scheduledCallbacks S
call:summaryWorkspaceLeanAAcall S, scheduledCallbacks S, summary S, summaryUpdatedAt S

call is omitted when the chat has no telephony call attached. call:completed fires for every disposition — completed, busy, no-answer, failed, canceled — so check call.status rather than assuming a conversation happened.

call:summary fires only for phone chats after the post-call summary is generated. For multi-channel summaries (SMS, web, email, …) use chat:summary instead.

Chat summaries (any channel)

EventScopeShapememberchatOther blocks
chat:summaryWorkspaceLeanAAsummary S, summaryUpdatedAt S, call S, scheduledCallbacks S

chat:summary fires after a chat summary is generated on any channel. Authors can narrow rules with trigger_params.channels and trigger_params.workflowUuids (empty = all). The optional call block is included only when the chat has a phone call.

Voicemail and fax

EventScopeShapememberchatOther blocks
voicemail:receivedWorkspaceVoicemailvoicemail A
fax:receivedWorkspaceFaxfax A

These two are workspace-global and carry no member block at all. Do not write a member lookup for them — there is nothing to look up. A voicemail links to a phone number and a call, not to an authenticated person; a fax sender is never authenticated. Reconcile on voicemail.callerNumber / fax.senderNumber, and treat both as unverified claims, never as identity.

A fax rule can run member-scoped actions (notify a person, open a chat) — those target the fax inbox owner your workspace configured on the receiving endpoint, never the sender. That owner is deliberately not described in the payload, so that a recipient cannot mistake them for the person who sent the fax. If you need it, read it from the webhook log's memberId.

Routines

EventScopeShapememberchatOther blocks
routine:triggeredRoutineLeanA

A Routine fires once per target member, so member is the member this run is for. There is no chat block — a routine has no conversation in scope at trigger time — and no routine block. A routine that was scheduled off a calendar event has that event in scope for its CEL conditions, but it does not appear in the payload.

Rules only fire while both the Routine and the rule are active.

Connector events

EventScopeShapememberchatOther blocks
connector:eventApp connectionLeanA

connector:event is managed by an app connection, not created by hand. It is rejected on the public API and hidden from public rule listings; the connector provisions and owns its own rules. Configure it from the connection's own screen.

Not deliverable

EventStatus
assignment:concludedDefined but not deliverable. It appears in the EventRuleEventType schema, and therefore in generated client types, but it is not a registered rule trigger: creating or updating a rule with it is rejected with 400 (the event type has no registered capabilities, so no action — including webhook:send — is compatible with it), and even a rule that somehow existed would match zero rules at dispatch time. Assignment-conclusion fan-out is internal by design. To be notified when an assignment ends, subscribe to call:completed for voice, or add a concluding task with a task:execute webhook rule.
chat:callbackRetired. Scheduled callbacks now run through an internal workflow. The trigger is rejected on the public API and hidden from public listings, and nothing fires it — if you still have a legacy rule using it, it is not delivering. Use scheduledCallbacks on call:completed / call:summary to see open AI-scheduled callbacks.

If you are enumerating event types programmatically, treat the generated enum as a superset. The authoritative check for whether an event type accepts webhook:send is:

curl -H "Authorization: Bearer $GR_API_KEY" \
"https://api.gravityrail.com/api/v2/w/$WORKSPACE_UUID/event-rules/actions/available"

Payload Shapes

Full JSON for every event is on the Webhook payloads page. This section explains the shared envelope and which of the five shapes each event uses.

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 — an opaque unique delivery identifier (string), generated once when the event fires. Retries of a failed delivery carry the same eventId, so use it as your dedup/idempotency key. Treat it as an opaque string, not as a UUID — most deliveries carry a UUID, but some (notably journey-triggered ones) carry a different short deterministic form. Validating it as a UUID will reject valid deliveries.

Every payload also carries eventRule and workspace:

  • eventRule{"id": <int>, "uuid": <string|null>, "name": <string|null>}. Identifies which rule produced this delivery.
  • workspace{"uuid": <string>, "name": <string>}.

Beyond that, the body takes one of five shapes, selected by the event type's prefix. The event catalog tells you which shape each event type uses:

ShapeEvent typesDistinguishing blocks
Data recorddata_record:*dataType, dataRecord, member, author
Membermember:*member (full profile incl. role), author
Voicemailvoicemail:*voicemail. No member, no chat.
Faxfax:*fax. No member, no chat.
Leaneverything elsemember when a member is in scope, chat when chat-scoped, call/summary/scheduledCallbacks on call:*

Two rules that hold across all five shapes:

  • Blocks are omitted, not nulled. If a payload has no chat in scope, there is no chat key at all — not "chat": null. Fields inside a block that is present are the opposite: they are present and may be null.
  • Keys arrive alphabetically sorted. The body is serialised with sorted keys, and the signed bytes are exactly the bytes on the wire. Verify signatures against the raw request body, never against a re-serialised object.

There is no top-level timestamp field, and no eventType field (an old alias that no longer exists — use event).

The context block

The change-context block on member:updated, data_record:updated, and data_record:changed — see Change context on the Webhook payloads page.

Data record events

data_record:created, data_record:updated, data_record:changed — see Data record on the Webhook payloads page.

Member events

member:updated — see Member updated on the Webhook payloads page.

Lean events (everything else)

Every event not in the data-record, member, voicemail, or fax families uses one lean shape: the envelope plus member when a member is in scope, chat when the event is chat-scoped, and call / summary / scheduledCallbacks on call:*. The Webhook payloads page has the body for each — e.g. chat:created, chat:summary, call, call:summary, and routine:triggered.

Voicemail events

voicemail:received — see Voicemail on the Webhook payloads page.

Fax events

fax:received — see Fax on the Webhook payloads page.

Receiving webhooks from your systems

Everything above is outbound — Gravity Rail calling you. The inbound direction, your system calling Gravity Rail, works differently: there is no general-purpose "post anything here" inbox, because an event needs to arrive attached to something that knows what to do with it.

Three inbound paths, in the order you should reach for them:

1. Write directly to the API. To create or update members, records, or chats, call the REST API with an API key. This is the right answer for most integrations — see Importing Data.

2. Trigger a Routine with a signed webhook. When you want an inbound event to run automation rather than write a row, enable the webhook trigger on a Routine. That gives you a URL to hand to your system:

POST https://api.gravityrail.com/api/v2/w/{workspace_uuid}/routines/{routine_uuid}/webhook
  • Authentication is an HMAC signature — there is no API key on this endpoint. Set webhookEnabled: true on the Routine and the server generates a per-Routine secret, returned as webhookSecretPlaintext exactly once in that response. Store it then; it is not retrievable afterwards. Rotate with rotateWebhookSecret: true. Setting webhookEnabled: false wipes the secret and existing senders stop working immediately.
  • Header: X-Routine-Signature
  • Format: t={unix_timestamp},v1={hex_digest} — the same construction as the outbound X-Webhook-Signature: HMAC-SHA256 over the ASCII string {timestamp}. concatenated with the raw body bytes. You can reuse the signing code from Request signing, swapping the header name and the secret.
  • Replay window: signatures older than 5 minutes are rejected, as are future-dated ones beyond 30 seconds of clock skew. Sign at send time, not at build time.
  • Body: any JSON, capped at 1 MB. The body is what you sign.
  • Success: 200 with {"run_uuid": "…", "status": "…"}. Keep run_uuid — it identifies the Routine run your delivery started.
  • Errors: 401 for a missing, malformed, expired, or incorrect signature; 404 if the Routine does not exist or its webhook trigger is not enabled (the two are deliberately indistinguishable); 413 over 1 MB. The endpoint is rate-limited per Routine and per source IP.
  • Retries are idempotent-safe. The dedup key is derived from your signature, so re-sending the same signed delivery reuses the same Routine run instead of starting a second one. Retry by replaying the identical body and the identical signature header — re-signing the same body with a new timestamp produces a new run.

3. Provider and app-connection ingress. Inbound SMS, voice, and fax arrive on provider-specific endpoints that Gravity Rail configures on your behalf when you provision a phone number — they authenticate with the provider's own signature scheme and are not open for you to call. Likewise, connected apps deliver their events to connection-specific URLs managed from that connection's settings. There is nothing to configure by hand for either. To react to those arrivals, use the outbound events above: chat:created, call:*, voicemail:received, fax:received.

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)

Send Test. The webhook action form has a Send Test button that posts a sample payload — with a real signature — to your URL without waiting for the event to happen. The body is editable, so it is the fastest way to exercise your handler end to end. Three things to know about it:

  • The sample is a starting point, not a specification. Dedicated samples exist for the data-record, member, chat, call, and voicemail families; every other event type falls back to a generic member-shaped sample. In particular the fax:received sample shows a member block that a real fax payload never sends, and the task, workflow, journey, calendar, routine, and connector fallbacks omit blocks those events do deliver. This page, not the sample, is the contract — build against the event catalog.
  • The sample's context block is illustrative. Use The context block above for the real shape.
  • A Send Test delivery does not write a webhook-log row and does not affect the rule's health status. Only real deliveries do.

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. In production, private, loopback, and link-local addresses and internal hostnames (.local, .internal, .localdomain) are rejected too — your endpoint must be publicly resolvable.
  • Handle retries - Implement idempotent handlers keyed on eventId (retries of the same delivery carry the same eventId)
  • Return 2xx fast - A 4xx is a permanent rejection and is never retried. Acknowledge first, process afterwards.
  • Verify against raw bytes - The body is serialised with sorted keys; re-serialising the parsed object before verifying will break the signature.
  • Don't infer the payload from the event name - Use the event catalog. Most events deliver the lean shape with no domain block, and four event types never deliver at all (assignment:concluded, chat:callback, workspace:member_joined, workspace:member_left).
  • Check logs - Every delivery attempt is recorded; correlate on dispatchKey, which equals the payload's eventId

For developers