How It Works Features Sign In

Developer guide

Webhooks

Push every Relay call directly into your own backend, CRM, or custom workflow. When something happens on a call, Relay sends an HTTPS request to a URL you control.

A call event flowing from Relay to a developer's server

Available on Team and Business plans

Custom webhooks are included on the Team ($64.99/mo) and Business ($199.99/mo) plans. Solo plan tenants who want call data in another system can still use Zapier, see the Zapier guide.

What is a webhook?

A webhook is a way for Relay to tell your server "something just happened" the moment it happens, without you having to ask. Instead of you calling our API every few minutes to check for new calls (polling), you give us a URL and we call you when there's something to share.

Mechanically, every event is a plain POST request to your URL with a JSON body. Your server reads the JSON, does whatever you want with it, and returns a 2xx response. That's the entire protocol.

Webhooks or Zapier, which do I want?

Both deliver the same call data. The difference is who writes the glue code.

Use Zapier if…

  • You want a no-code setup
  • Your destination is a common app (Sheets, Slack, HubSpot, Twilio SMS, etc.)
  • You're fine with Zapier's monthly task limits and pricing
  • You want to be running in 5 minutes

Read the Zapier guide →

Use webhooks if…

  • You have (or will write) a server that can receive HTTPS POSTs
  • You need custom logic, enrichment, routing, conditional processing
  • Your destination isn't on Zapier, or you need lower per-call cost
  • You want to handle high call volume without per-task fees

You're already reading the right page.

You can use both. A Zap and a custom webhook on the same event fire independently, they don't conflict.

What you'll need

  • A Relay subscription on the Team or Business plan
  • A public HTTPS endpoint to receive requests (http:// is rejected for security reasons). For local development, tunnel it with ngrok or similar.
  • Any language/framework that can parse JSON and compute HMAC-SHA256 (every modern stack can, examples in Node, Python, and Ruby below)

Setting up a webhook

1

Build a receiving endpoint

Spin up an HTTPS route on your server that:

  1. Accepts POST
  2. Reads the request body as raw bytes before parsing JSON, you'll need them as-is to verify the signature
  3. Verifies the signature (see step 4)
  4. Returns 200 within 10 seconds

The endpoint can do anything inside, write to a database, enqueue a job, send a Slack message, forward to another service. As long as it responds 2xx fast, you're good.

2

Register the webhook in Relay

Open Dashboard → Webhooks and click Add webhook. Fill in:

  • URL, the HTTPS endpoint from step 1
  • Events, check any subset of call.completed, call.urgent, appointment.booked
  • Description (optional), a label so future-you remembers what this hook is for

Click Create and Relay returns a one-time signing secret.

3

Save the signing secret

The secret is shown once. Copy it immediately and store it as an environment variable in your server's config (something like RELAY_WEBHOOK_SECRET). Treat it like any other API credential, don't commit it to git, don't paste it into Slack.

If you lose it, you can rotate it from the dashboard, that invalidates the old secret and gives you a fresh one.

4

Verify the signature on every request

Every delivery includes two headers Relay's server signs with your secret:

  • X-Relay-Timestamp, unix seconds (used to reject replays)
  • X-Relay-Signature, sha256=<hex>

Recompute the signature on your side and constant-time compare. Code snippets in the signature verification section below.

5

Send a test delivery

Back on Dashboard → Webhooks, click the Test button on your new webhook. Relay sends a synthetic payload (the same shape as a real call) so you can verify your endpoint, signature check, and downstream logic without making an actual phone call.

Hit the View log button to see the delivery result. If you get a 200, you're done, the next real call that matches one of your subscribed events will fire automatically.

The three events

A single webhook can subscribe to any combination, you don't need three separate webhooks unless you want to route them to different URLs.

Every completed call flows into your tools
Event call.completed

Fires after every completed call, receptionist mode, assistant mode, demo mode. Delivered once Relay has transcribed the call and produced the summary (usually within 30–60 seconds of hangup).

Payload includes

Caller phone + name, the number they dialed, duration, billable minutes, call summary, urgency, mode, transcript URL, any booked appointments, and the call's stable identifier.

Common use cases

  • Sync to your CRM, upsert a contact, log the call as an activity
  • Archive to a data warehouse, BigQuery, Snowflake, Postgres, for analytics
  • Trigger your own follow-up flow, drip campaigns, internal routing, lead scoring
  • Fan out to multiple systems, your server can decide where each call should go
  • Custom alerts, Slack/Teams/Discord with logic Zapier can't easily express
Urgent calls routed to the right place fast
Event call.urgent

Same payload as call.completed, but fires only for calls Relay's classifier flags as urgent. You don't configure the classifier, it reads the transcript and caller tone automatically.

Common use cases

  • Page on-call staff, PagerDuty, Opsgenie, or your own paging stack
  • SMS or call yourself instantly via Twilio, Vonage, or your own SMS gateway
  • Open a high-priority ticket in your help desk with a 30-minute SLA
  • Push a notification to a mobile app via FCM or APNs
  • Wake a Slack workflow that escalates if nobody acknowledges in N minutes
Appointments booked over the phone, synced everywhere
Event appointment.booked

Fires when Relay books an appointment during a call. (Requires Google Calendar connected in Dashboard → Calendar.) If a single call books multiple appointments, the event fires once per appointment.

Payload includes

Start / end UTC, time zone, caller name, caller phone, stated purpose, the appointment's event ID, and the originating call's SID for cross-referencing.

Common use cases

  • Mirror into another calendar system, Outlook, Apple, internal scheduling
  • Block the slot in a booking tool, Calendly, Acuity, Cal.com, to prevent double-booking
  • Create a CRM deal/opportunity with the appointment attached
  • Schedule reminder SMS 24 hours and 1 hour before
  • Pre-create video meeting rooms, Zoom, Google Meet, Whereby

Payload reference

Every delivery is a POST with a JSON body in this envelope:

{
  "version": "2026-05-05",
  "event": "call.completed",
  "timestamp": "2026-05-05T14:23:00Z",
  "webhookId": "whk_abc123",
  "data": { ... event-specific fields ... }
}

version is the contract version. We bump it on breaking shape changes, treat unrecognized versions as a signal to update your integration before processing. Additive fields (new optional keys inside data) are not breaking and don't bump the version.

call.completed / call.urgent sample

{
  "version": "2026-05-05",
  "event": "call.completed",
  "timestamp": "2026-05-05T14:23:00Z",
  "webhookId": "whk_abc123",
  "data": {
    "sid": "CA...",
    "tenantId": "tnt_...",
    "caller": "+12125551234",
    "callerName": "John Smith",
    "calledLine": "+18885559876",
    "duration": 142,
    "billableMinutes": 3,
    "startedAt": "2026-05-05T14:20:38Z",
    "endedAt": "2026-05-05T14:23:00Z",
    "endReason": "caller-hangup",
    "summary": "Caller asked about pricing for the team plan...",
    "urgency": "normal",
    "mode": "receptionist",
    "appointmentsBooked": [],
    "transcriptUrl": "https://api.relay.sandybrook.io/api/calls/CA.../transcript",
    "redirect": null
  }
}

appointment.booked sample

{
  "version": "2026-05-05",
  "event": "appointment.booked",
  "timestamp": "2026-05-05T14:23:00Z",
  "webhookId": "whk_abc123",
  "data": {
    "sid": "CA...",
    "tenantId": "tnt_...",
    "eventId": "evt_...",
    "startUtc": "2026-05-07T15:00:00Z",
    "endUtc": "2026-05-07T15:30:00Z",
    "timeZoneId": "America/New_York",
    "callerName": "John Smith",
    "callerPhone": "+12125551234",
    "purpose": "Initial consultation"
  }
}

Signature verification

Verify the signature on every request. Without it, anyone who guesses your URL can send fake call data into your system.

The signature is HMAC-SHA256 over {timestamp}.{rawBody}, the X-Relay-Timestamp header value, a literal period, then the exact raw request body bytes (before any JSON parsing or middleware re-encoding). Compute on your side, constant-time compare. Reject any request older than ~5 minutes (compare the timestamp to your server clock) to prevent replay attacks.

Node.js (Express)

const crypto = require("crypto");
const express = require("express");
const app = express();

// IMPORTANT: capture the raw body for HMAC verification
app.use("/relay-hook", express.raw({ type: "application/json" }));

app.post("/relay-hook", (req, res) => {
  const timestamp = req.header("X-Relay-Timestamp");
  const signature = req.header("X-Relay-Signature");
  const rawBody = req.body; // Buffer

  if (!verify(rawBody, timestamp, signature, process.env.RELAY_WEBHOOK_SECRET)) {
    return res.status(401).send("bad signature");
  }
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
    return res.status(401).send("stale timestamp");
  }

  const payload = JSON.parse(rawBody.toString("utf8"));
  // ...do your thing...
  res.status(200).send("ok");
});

function verify(rawBody, timestamp, sigHeader, secret) {
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");
  const provided = sigHeader.replace(/^sha256=/, "");
  return crypto.timingSafeEqual(
    Buffer.from(expected, "hex"),
    Buffer.from(provided, "hex"),
  );
}

Python (Flask)

import hmac, hashlib, os, time
from flask import Flask, request, abort

app = Flask(__name__)
SECRET = os.environ["RELAY_WEBHOOK_SECRET"]

@app.post("/relay-hook")
def relay_hook():
    timestamp = request.headers.get("X-Relay-Timestamp", "")
    signature = request.headers.get("X-Relay-Signature", "")
    raw_body = request.get_data()  # bytes, before JSON parsing

    if not verify(raw_body, timestamp, signature, SECRET):
        abort(401)
    if abs(time.time() - int(timestamp)) > 300:
        abort(401)

    payload = request.get_json()
    # ...do your thing...
    return "ok", 200

def verify(raw_body, timestamp, sig_header, secret):
    payload = f"{timestamp}.{raw_body.decode('utf-8')}".encode()
    expected = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()
    provided = sig_header.removeprefix("sha256=")
    return hmac.compare_digest(expected, provided)

Ruby (Rails)

require "openssl"

class RelayHookController < ApplicationController
  skip_before_action :verify_authenticity_token

  def receive
    raw_body  = request.raw_post
    timestamp = request.headers["X-Relay-Timestamp"]
    signature = request.headers["X-Relay-Signature"]

    return head :unauthorized unless verify(raw_body, timestamp, signature)
    return head :unauthorized if (Time.now.to_i - timestamp.to_i).abs > 300

    payload = JSON.parse(raw_body)
    # ...do your thing...
    head :ok
  end

  private

  def verify(raw_body, timestamp, sig_header)
    expected = OpenSSL::HMAC.hexdigest("sha256", ENV["RELAY_WEBHOOK_SECRET"], "#{timestamp}.#{raw_body}")
    provided = sig_header.to_s.sub(/^sha256=/, "")
    Rack::Utils.secure_compare(expected, provided)
  end
end

Retry and idempotency

Return 2xx within 10 seconds and the delivery is marked successful. Any other status (or no response) and Relay retries on a managed queue:

Make your endpoint idempotent

Because each attempt is independent, your endpoint may receive the same event more than once. Use webhookId (in the envelope) or data.sid (on call events) / data.eventId (on appointment events) as a dedupe key. Store the key after first successful processing; reject duplicates with a 200 so Relay doesn't retry.

Managing your webhooks

Everything lives on Dashboard → Webhooks. Each row has these controls:

Test

Send a synthetic payload to your URL, same shape as a real event. Verifies your endpoint and signature check before you wait for a real call.

View log

30 days of delivery history per webhook, every attempt, response code, latency, and the request/response bodies. Includes a Retry now button for failed deliveries.

Pause / Resume

Stop deliveries temporarily without losing the configuration. Useful during maintenance on your receiving server.

Rotate secret

Issue a fresh signing secret. The old one is invalidated immediately, your endpoint will start failing verification until you update its environment variable.

Troubleshooting

Signature verification keeps failing

Almost always one of these:

  • You're hashing the parsed body, not the raw bytes. Most frameworks parse JSON before your handler runs, then re-serialize it differently than Relay did. Grab request.raw / req.body as a Buffer / request.get_data(), whatever your framework calls "the bytes before parsing."
  • You forgot the literal period. The signed string is timestamp + "." + body, not just the body.
  • You're hashing the sha256= prefix. Strip it from the incoming header before comparing.
  • Wrong secret. If you rotated, did you update the env var on every server instance?

Delivery log shows my endpoint returned 5xx / timed out

You have 10 seconds to respond. If your handler does heavy work (sending email, calling slow APIs, generating reports), enqueue a background job and return 200 immediately. Don't process synchronously inside the handler.

I'm getting the same event twice

That's by design, at-least-once delivery. Dedupe on webhookId or the event-specific ID (data.sid for calls, data.eventId for appointments). See the Retry section above.

My ngrok URL keeps changing during development

That's a free-ngrok limitation. Either pay for a reserved domain or update the URL on the webhook from the dashboard each session. The signing secret stays the same when you only change the URL.

I expected a webhook to fire and nothing happened

Check, in order:

  1. Is the webhook Active? (Paused webhooks queue nothing.)
  2. Did you subscribe to the right event? (A webhook only fires for events it's checked.)
  3. Did the call actually complete? Failed/abandoned calls don't fire call.completed.
  4. Was the call flagged urgent for call.urgent? Check the call's urgency in Dashboard → Calls.
  5. Look at the View log for the webhook, if it shows zero attempts, the event didn't match. If it shows attempts but they're all failing, the issue is downstream.

Common questions

How many webhooks can I create?

Team plan up to 5 custom webhooks; Business up to 20. Solo doesn't include custom webhooks, upgrade to Team or use Zapier instead. Webhooks created through Zapier are budgeted separately and don't compete for this limit.

Does Relay support custom event types or filters?

Not yet, the three built-in events cover most use cases. Do filtering in your endpoint (the payload includes mode, urgency, calledLine, etc.).

Can I receive call audio or the transcript inline?

The payload includes transcriptUrl, fetch it on demand with your API key. Audio recordings aren't included in the webhook body for size reasons; access them through the Relay API.

What if my server is offline?

Relay retries for up to 24 hours. After that the delivery is marked failed and shows up in the log with the final error. You can manually trigger a re-send from the log within the 30-day log retention window.

Does Relay support IP allow-listing?

Outbound webhooks come from Google Cloud Run, which uses a large dynamic IP range. We recommend HMAC signature verification (which is cryptographically strong) instead of IP allow-listing. If you have a hard requirement for a static egress IP, get in touch.

Ready to wire up your first webhook?

Spin up a tiny endpoint, register it on the dashboard, click Test, and you're done. Most integrations take under an hour end-to-end.