Receiving results
Results reach you two ways, and both carry the same events.
| Webhooks | Event feed | |
|---|---|---|
| Direction | Pace calls you | You call Pace |
| Needs a public endpoint | Yes | No |
| Latency | Seconds | Your polling interval |
| Retention | 72-hour retry window | 30 days |
| Best for | Production | Reconciliation, development, catching up |
Use both. Webhooks for normal operation, the feed to reconcile - and the feed is a complete reconciliation path, not a subset, because the feed is written before delivery is attempted. There can be no delivered event that is missing from the feed.
Registering an endpoint
Section titled “Registering an endpoint”curl -sS -X POST https://api.pacepayments.ai/v1/webhook-endpoints \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "url": "https://hooks.example.org/pace/v1", "event_types": ["call_order.*", "suppression.*"], "payload_mode": "full" }'The URL must be HTTPS, publicly resolvable, on port 443 or 8443. It is checked at registration and again before every dispatch - a hostname that was public yesterday can point at an internal address today.
The response carries secret once. Store it now; it is never retrievable again.
The verification challenge
Section titled “The verification challenge”A new endpoint starts in pending_verification, and nothing is delivered to it
until it echoes a challenge back. Events accumulate in the feed meanwhile, so
nothing is lost.
Pace sends a webhook_endpoint.verification event containing a challenge value.
Your endpoint must return it in the response body:
{ "challenge": "chl_7f8c9a4e2b1d" }A 2xx alone is not enough. The echo is how Pace establishes that whoever is on the other end can actually read the payload - plenty of things return 200 to anything.
If the challenge is not echoed before verification.expires_at, the endpoint moves
to disabled and a webhook_endpoint.verification_failed event goes to your
remaining active endpoints.
payload_mode
Section titled “payload_mode”full (default) includes the whole resource in data. thin sends the envelope
with related but no data, and you fetch the resource yourself.
Verifying the signature
Section titled “Verifying the signature”Every delivery carries:
Pace-Signature: t=1722151041,v1=5f2a9c…The signature is HMAC-SHA256 over t.payload - the timestamp, a full stop, then
the raw request body bytes. Not the re-serialised JSON: two serialisers place
whitespace and order keys differently, and then the signature fails while both sides
mean the same thing. Sign the bytes you received.
Three rules that integrations get wrong:
-
The timestamp is part of the signature
That is why it is in the header. Without it, an intercepted delivery could be replayed indefinitely and would verify correctly every time. Reject anything outside your tolerance - 300 seconds is the recommended value.
-
There may be more than one v1 value
During a secret rotation, two
v1entries appear in the same header, old secret first. Parse the header as a comma-separated list ofk=vpairs and collect allv1values. Code that checks only the first works for 24 hours and then fails. -
Compare in constant time
Use your platform’s constant-time comparison, not
==.
# Verify a captured delivery from the shell (for debugging, not production).SECRET="whsec_…"BODY=$(cat body.json) # raw bytes, exactly as receivedHEADER="t=1722151041,v1=5f2a9c…"
TS=${HEADER#t=}; TS=${TS%%,*}SIGS=$(echo "$HEADER" | tr ',' '\n' | grep '^v1=' | cut -d= -f2)
EXPECTED=$(printf '%s.%s' "$TS" "$BODY" \| openssl dgst -sha256 -hmac "$SECRET" -r | cut -d' ' -f1)
echo "$SIGS" | grep -qx "$EXPECTED" && echo "valid" || echo "INVALID"import hashlibimport hmacimport time
TOLERANCE_SECONDS = 300
def verify(secret: str, header: str, body: bytes) -> bool:"""`body` must be the raw bytes received, never re-serialised JSON."""timestamp: str | None = Nonesignatures: list[str] = []
for part in header.split(","): key, _, value = part.strip().partition("=") if key == "t": timestamp = value elif key == "v1": signatures.append(value) # several during a rotation
if timestamp is None or not signatures: return False
if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS: return False # replay
expected = hmac.new( secret.encode("utf-8"), f"{timestamp}.".encode("ascii") + body, hashlib.sha256,).hexdigest()
return any(hmac.compare_digest(expected, s) for s in signatures)import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SECONDS = 300;
export function verify(secret: string, header: string, body: Buffer): boolean {let timestamp: string | undefined;const signatures: string[] = [];
for (const part of header.split(",")) {const [key, ...rest] = part.trim().split("=");const value = rest.join("=");if (key === "t") timestamp = value;else if (key === "v1") signatures.push(value); // several during a rotation}
if (!timestamp || signatures.length === 0) return false;
const age = Math.abs(Date.now() / 1000 - Number(timestamp));if (age > TOLERANCE_SECONDS) return false; // replay
const expected = createHmac("sha256", secret).update(Buffer.concat([Buffer.from(`${timestamp}.`, "ascii"), body])).digest("hex");
const want = Buffer.from(expected, "hex");return signatures.some((s) => {const got = Buffer.from(s, "hex");return got.length === want.length && timingSafeEqual(got, want);});}using System.Security.Cryptography;using System.Text;
public static class PaceWebhook{private const int ToleranceSeconds = 300;
// `body` must be the raw bytes received, never re-serialised JSON.public static bool Verify(string secret, string header, byte[] body){ string? timestamp = null; var signatures = new List<string>();
foreach (var part in header.Split(',')) { var kv = part.Trim().Split('=', 2); if (kv.Length != 2) continue; if (kv[0] == "t") timestamp = kv[1]; if (kv[0] == "v1") signatures.Add(kv[1]); // several during a rotation }
if (timestamp is null || signatures.Count == 0) return false;
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); if (Math.Abs(now - long.Parse(timestamp)) > ToleranceSeconds) return false;
var prefix = Encoding.ASCII.GetBytes(timestamp + "."); var message = new byte[prefix.Length + body.Length]; Buffer.BlockCopy(prefix, 0, message, 0, prefix.Length); Buffer.BlockCopy(body, 0, message, prefix.Length, body.Length);
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret)); var expected = hmac.ComputeHash(message);
foreach (var s in signatures) { var got = Convert.FromHexString(s); if (CryptographicOperations.FixedTimeEquals(got, expected)) return true; } return false;}}Rotating the secret
Section titled “Rotating the secret”curl -sS -X POST "https://api.pacepayments.ai/v1/webhook-endpoints/$ID/rotate-secret" \ -H "Authorization: Bearer $TOKEN"Both secrets sign every delivery for 24 hours. Store the new one alongside the old, verify against both - the code above already does - and drop the old one once the overlap has passed. Nothing needs to be coordinated with a deployment.
Responding to a delivery
Section titled “Responding to a delivery”Return 2xx quickly. Anything else counts as a failure and is retried.
Do the work asynchronously: acknowledge, enqueue, process. A handler that writes to your case system inline will eventually time out during an unrelated database slowdown, and then you are retrying deliveries because of a problem that has nothing to do with Pace.
Retries
Section titled “Retries”Failures are retried inside a 72-hour envelope, backing off through roughly: immediately, 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours, then every 12 hours. Each interval carries up to 10 per cent jitter, so that a thousand queued deliveries do not all knock on the door in the same second and knock over the receiver that has just recovered.
After 72 hours the delivery is dead-lettered. It stays visible in
GET /v1/webhook-deliveries?status=failed, and it is still in the feed - which is
what makes recovery possible.
Automatic disabling
Section titled “Automatic disabling”An endpoint that fails continuously is disabled: after 200 consecutive failures, or
after 5 days of unbroken failure. A webhook_endpoint.disabled event goes to your
other active endpoints.
Re-enable by setting status: "active" through PATCH, once the endpoint works
again. Fix first, then re-enable - a disabled endpoint that is re-enabled while still
broken simply disables itself again.
Deduplication
Section titled “Deduplication”Deduplicate on event.id. It is stable across retries and replays - replayed
events reappear under their original ID rather than getting a new one, which is what
makes replay safe to run against a live consumer.
if seen.contains(event["id"]): return 200seen.add(event["id"], ttl=days(31)) # one day longer than the feed's 30process(event)Tolerant reading
Section titled “Tolerant reading”Deliveries change over time without a version bump. A client that survives:
- Ignores unknown event types. Acknowledge with 2xx and move on.
- Ignores unknown enum values. Map an unfamiliar
summary_codeoroutcomes[].typeonto your own “other” bucket rather than throwing. - Tolerates new fields. Never fail on an unexpected key.
- Handles duplicates. At-least-once delivery is the contract.
- Handles out-of-order arrival. A retry can land after a later event succeeded.
- Checks
environmentbefore doing anything. See Environments.
Polling the feed
Section titled “Polling the feed”curl -sS "https://api.pacepayments.ai/v1/events?after=evt_01J32K9Q&event_types=call_order.*" \ -H "Authorization: Bearer $TOKEN"after accepts either an opaque cursor from a previous page or a bare evt_…
ID, so you can resume from the last event you successfully processed without storing
a separate cursor.
The feed retains 30 days. created_after must not predate that window - a request
that does would silently return too little, so it is rejected instead.
Replaying
Section titled “Replaying”curl -sS -X POST "https://api.pacepayments.ai/v1/webhook-endpoints/$ID/replay" \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "after": "evt_01J32K9Q", "dry_run": true }'The range must be bounded: exactly one of after or since. An unbounded replay
would re-dispatch the entire retained feed, and that is refused rather than guessed
at.
Run with dry_run: true first. It counts the matches and dispatches nothing - worth
doing before any wide range.
Event types
Section titled “Event types”| Class | Types |
|---|---|
| Call orders | accepted, scheduled, in_progress, completed, canceled, expired, failed |
| Calls | call.started, call.ended |
| Enrollments | created, step_advanced, paused, resumed, completed, exited, canceled, failed |
| Suppressions | suppression.created |
| Endpoints | webhook_endpoint.verification, .verification_failed, .disabled |
| Test | ping |
Subscribe with exact types or a resource wildcard (call_order.*). Omitting
event_types gives you the default subscription.