Skip to content

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.

Terminal window
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.

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.

full (default) includes the whole resource in data. thin sends the envelope with related but no data, and you fetch the resource yourself.

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:

  1. 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.

  2. There may be more than one v1 value

    During a secret rotation, two v1 entries appear in the same header, old secret first. Parse the header as a comma-separated list of k=v pairs and collect all v1 values. Code that checks only the first works for 24 hours and then fails.

  3. Compare in constant time

    Use your platform’s constant-time comparison, not ==.

Terminal window
# Verify a captured delivery from the shell (for debugging, not production).
SECRET="whsec_…"
BODY=$(cat body.json) # raw bytes, exactly as received
HEADER="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"
Terminal window
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.

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.

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.

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.

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 200
seen.add(event["id"], ttl=days(31)) # one day longer than the feed's 30
process(event)

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_code or outcomes[].type onto 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 environment before doing anything. See Environments.
Terminal window
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.

Terminal window
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.

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.