Skip to main content

Webhooks

Webhooks let your systems react to what happens on Bolti without polling. Subscribe an HTTPS endpoint to one or more events; Bolti POSTs an HMAC-SHA256-signed JSON payload there every time a matching event fires, with durable retries on failure.

For exact request/response shapes for every endpoint mentioned on this page, browse the API Reference.

When to use them

  • Update your CRM when a call finishes.
  • Reconcile billing against conversation.completed events.
  • Fan out alerts when a scheduled call fails.
  • Trigger downstream automation when a campaign finishes.

If you find yourself polling the API on a timer, a webhook is almost certainly the right answer.

Event catalog

EventFires when
scheduled_call.createdA scheduled call row is materialized (ad-hoc, recurring, or bulk).
scheduled_call.dispatchedThe dispatcher successfully handed a scheduled call off to telephony.
scheduled_call.failedA scheduled call exhausted its retry budget without dispatching.
scheduled_call.cancelledA pending scheduled call was cancelled (by user or by campaign cancel).
campaign.completedA bulk campaign exhausted all targets, or a recurring campaign reached end_at.
conversation.completedA conversation transitioned to completed and was billed.

The allow-list is enforced server-side at subscription time — unknown event types are rejected.

Configure an endpoint

Dashboard → Settings → Webhooks → Add endpoint
  1. Enter the receiver URL. Use HTTPS in production — Bolti accepts HTTP only with a warning, and we may stop allowing it.
  2. Pick the events to subscribe to.
  3. Save. Bolti shows the signing secret once — store it somewhere safe (env var, secret manager). You won't see the same secret again.

Endpoints can also be created, listed, updated, deleted, rotated, tested, and replayed via the API. → See API Reference.

Delivery semantics

Every event is recorded in a durable outbox inside the same database transaction as the change that produced it. That gives you two guarantees:

  • At-least-once delivery — you may see retries, never silent drops.
  • Atomic with the change — if the producer transaction fails, no event is emitted.

Your receiver must therefore be idempotent. Use the id field in the payload to dedupe.

Retry policy

TryDelay before next attempt
1immediate
2~30 s
3~2 min
4~10 min
5~30 min
6~1 h

After the 6th failure the delivery moves to dead-letter. You can replay a dead-lettered delivery from the dashboard's audit log, or via the API.

A delivery is considered successful on any 2xx response. Non-2xx, network errors, and timeouts (currently 10 s) all count as failures.

Auto-disable

If an endpoint accumulates too many consecutive terminal failures, Bolti auto-disables it and stops new deliveries until you re-enable it. Visit Settings → Webhooks to fix the URL or reachability and click Enable.

Signature verification

Every POST includes:

  • X-Voiceai-Event — the event type string.
  • X-Voiceai-Signature — comma-separated t=<unix_ts>,v1=<digest>[,v1=<digest>].
  • The body is application/json.

The digest is HMAC-SHA256(secret, "<unix_ts>.<raw_body>"), hex-encoded.

Verification, in pseudo-code:

function verify(req, secret) {
const header = req.headers["x-voiceai-signature"];
const parts = Object.fromEntries(
header.split(",").map(p => p.split("="))
);
const t = parts.t;
// Reject anything older than 5 minutes.
if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;

const expected = hmacSha256Hex(secret, `${t}.${req.rawBody}`);

// Header may carry multiple v1=... segments during a rotation
// grace window — accept if ANY matches.
const all = header.split(",")
.filter(p => p.startsWith("v1="))
.map(p => p.slice(3));
return all.includes(expected);
}

Critical:

  • Verify against the raw request body, byte-for-byte. Re-serializing JSON will change the bytes and your verification will fail.
  • Constant-time compare your computed digest against the header digest. Standard string equality is OK but crypto.timingSafeEqual (Node) or hmac.compare_digest (Python) is better.
  • Reject deliveries with old t timestamps to defend against replay attacks.

Rotating the signing secret

Settings → Webhooks → … → Rotate secret

Bolti returns a new secret. For 24 hours after rotation, every delivery is signed with both the new and the previous secret, and the X-Voiceai-Signature header carries both digests:

t=1714572123,v1=<new_digest>,v1=<old_digest>

Receivers that already validate against either secret will pass through. After 24 hours the old secret is dropped and only the new one signs.

This means you can deploy the new secret on your own schedule without dropping events. The recommended sequence:

  1. Click Rotate secret in the dashboard.
  2. Copy the new secret into your secret manager.
  3. Roll your receivers to the new secret within the 24-hour window.
  4. Done — Bolti drops the old secret automatically.

Replaying a dead-letter delivery

Pick a delivery from the audit log and click Replay this delivery. Bolti re-queues it as pending; the next webhook tick re-attempts delivery with a fresh retry budget. Useful when you've deployed a fix to the receiver and want to backfill missed events.

Sending a test event

Click Send test event on any endpoint to fire a synthetic test payload. Useful for sanity-checking signature verification when you first deploy a receiver, without waiting for a real call to happen.

Best practices

  • Verify the signature on every request. An unsigned receiver is a public webhook receiver.
  • Be idempotent. Use the payload id field as the dedupe key.
  • Respond quickly. Acknowledge with a 2xx as soon as you've persisted the event; do downstream work asynchronously. Receivers that take more than ~10 s are treated as failures.
  • Log raw deliveries during integration. When verification fails, the cause is almost always a body re-serialization or a header parsing bug — having the raw bytes makes both obvious.
  • Use HTTPS. No exceptions in production.

Troubleshooting

SymptomLikely cause
All deliveries fail with 4xxYour endpoint is rejecting the signature or the path is wrong. Check the audit log's last_response_body.
Endpoint auto-disabledPersistent 5xx or unreachable. Re-enable after fixing.
You see duplicate eventsExpected — retry semantics are at-least-once. Dedupe on payload id.
Missing eventsYour subscription doesn't include that event type. Edit the endpoint and add it.
Signature fails after rotateThe 24-hour grace ended before you deployed the new secret, or you're verifying against a re-serialized body.

Next steps