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") — see the event catalog for every available trigger and the payload it delivers
- 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.
Delivery
| Method | POST |
Content-Type | application/json |
| Body | JSON with alphabetically sorted keys, UTF-8 |
| Signature header | X-Webhook-Signature (only when the rule has a signing secret) |
| Custom headers | Not supported — a delivery sends Content-Type and, when signing is enabled, X-Webhook-Signature. Nothing else |
| Timeout | 30 seconds to first response |
| Success | any 2xx |
| Retries | up to 5 attempts total, exponential backoff from 10 seconds (10s, 20s, 40s, 80s), capped at 5 minutes |
What is and is not retried:
- Retried:
5xxresponses, timeouts, and transient connection errors. - Not retried: any
4xx. A4xxis treated as a permanent rejection — the delivery is logged as failed and the rule is flagged, but nothing is resent. Return2xxas soon as you have durably accepted the payload and do your own processing asynchronously; do not return4xxfor "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
| Event | Scope | Shape | member | chat | Other blocks |
|---|---|---|---|---|---|
data_record:created | Form (DataType) | Data record | A | — | dataType A, dataRecord A, author A |
data_record:updated | Form (DataType) | Data record | A | — | dataType A, dataRecord A, author A, context S |
data_record:changed | Form (DataType) | Data record | A | — | dataType 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
| Event | Scope | Shape | member | chat | Other blocks |
|---|---|---|---|---|---|
member:updated | Workspace | Member | A | — | author A, context A |
workspace:member_joined | Workspace | Lean | A | — | — |
workspace:member_left | Workspace | Lean | A | — | — |
workspace:member_joinedandworkspace:member_leftdo 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 adata_record:*rule on your intake form.
Chat and messages
| Event | Scope | Shape | member | chat | Other blocks |
|---|---|---|---|---|---|
chat:created | Workspace | Lean | A | A | — |
chat:summary | Workspace | Lean | A | A | summary S, summaryUpdatedAt S, call S, scheduledCallbacks S |
workflow:user_message | Workflow revision | Lean | A | A | — |
workflow:assistant_message | Workflow revision | Lean | A | A | — |
chat:summary is detailed under Chat summaries.
Workflow and task lifecycle
| Event | Scope | Shape | member | chat | Other blocks |
|---|---|---|---|---|---|
workflow:execute | Workflow revision | Lean | A | A | — |
task:execute | Task | Lean | A | A | — |
task:enter | Task | Lean | A | A | — |
task:command | Task | Lean | A | A | — |
task:exit | Task | Lean | A | A | — |
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
| Event | Scope | Shape | member | chat | Other blocks |
|---|---|---|---|---|---|
calendar_event:created | Calendar or Event Type | Lean | A | — | — |
calendar_event:updated | Calendar or Event Type | Lean | A | — | — |
calendar_event:cancelled | Calendar or Event Type | Lean | A | — | — |
calendar_event:starting | Calendar or Event Type | Lean | A | — | — |
calendar_event:ended | Calendar or Event Type | Lean | A | — | — |
The payload carries no
calendar_eventblock, nochatblock, and nocontext. 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 todata_record:changedinstead.
memberis 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
| Event | Scope | Shape | member | chat | Other blocks |
|---|---|---|---|---|---|
journey:enrolled | Journey | Lean | A | — | — |
journey:completed | Journey | Lean | A | — | — |
journey_step:execute | Journey step | Lean | A | — | — |
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
| Event | Scope | Shape | member | chat | Other blocks |
|---|---|---|---|---|---|
call:started | Workspace | Lean | A | A | call S |
call:completed | Workspace | Lean | A | A | call S, scheduledCallbacks S |
call:summary | Workspace | Lean | A | A | call 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)
| Event | Scope | Shape | member | chat | Other blocks |
|---|---|---|---|---|---|
chat:summary | Workspace | Lean | A | A | summary 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
| Event | Scope | Shape | member | chat | Other blocks |
|---|---|---|---|---|---|
voicemail:received | Workspace | Voicemail | — | — | voicemail A |
fax:received | Workspace | Fax | — | — | fax A |
These two are workspace-global and carry no
memberblock 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 onvoicemail.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
| Event | Scope | Shape | member | chat | Other blocks |
|---|---|---|---|---|---|
routine:triggered | Routine | Lean | A | — | — |
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
| Event | Scope | Shape | member | chat | Other blocks |
|---|---|---|---|---|---|
connector:event | App connection | Lean | A | — | — |
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
| Event | Status |
|---|---|
assignment:concluded | Defined 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:callback | Retired. 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 sameeventId, 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:
| Shape | Event types | Distinguishing blocks |
|---|---|---|
| Data record | data_record:* | dataType, dataRecord, member, author |
| Member | member:* | member (full profile incl. role), author |
| Voicemail | voicemail:* | voicemail. No member, no chat. |
| Fax | fax:* | fax. No member, no chat. |
| Lean | everything else | member 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
chatin scope, there is nochatkey at all — not"chat": null. Fields inside a block that is present are the opposite: they are present and may benull. - 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: trueon the Routine and the server generates a per-Routine secret, returned aswebhookSecretPlaintextexactly once in that response. Store it then; it is not retrievable afterwards. Rotate withrotateWebhookSecret: true. SettingwebhookEnabled: falsewipes the secret and existing senders stop working immediately. - Header:
X-Routine-Signature - Format:
t={unix_timestamp},v1={hex_digest}— the same construction as the outboundX-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:
200with{"run_uuid": "…", "status": "…"}. Keeprun_uuid— it identifies the Routine run your delivery started. - Errors:
401for a missing, malformed, expired, or incorrect signature;404if the Routine does not exist or its webhook trigger is not enabled (the two are deliberately indistinguishable);413over 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
- 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)
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:receivedsample shows amemberblock 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
contextblock is illustrative. Use Thecontextblock 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 withhttp://URLs are also blocked at delivery time and surfaced aswebhook_url_blockedin the rule UI — update the URL tohttps://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 sameeventId) - Return
2xxfast - A4xxis 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'seventId
Related
- Actions — Configure webhook triggers and conditions
- CEL Expressions — Write conditions to control when webhooks fire
- Template Variables — Dynamic variables available in webhook payloads
For developers
- Event Rules API — create and manage webhook-triggering rules programmatically
- Webhook Logs API — inspect webhook delivery attempts and failures
- Quick Reference — webhook event reference and payload format