Webhooks
Subscribe to submission events, verify the signature, and handle retries.
Webhooks push submission events to your endpoint instead of you polling for them. The contract is a conventional one: a signed POST, an idempotency header, and exponential backoff on failure, so a receiver written for any mainstream provider works here with a different header name.
One endpoint is one subscription: it has a URL, a list of the events it receives, and one signing secret. Adding a second event to the same receiver is a change to that subscription, not a second one, so a receiver never has more than one secret to verify with.
Setting one up
Two calls: create the subscription, then confirm it.
1. Create a subscription
curl -X POST https://terac.com/api/external/v2/hooks/subscriptions \
-H "Authorization: Bearer $TERAC_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"target_url": "https://yourapp.com/hooks/terac",
"event_types": ["submission.status.change", "submission.approved"]
}'{
"id": "whs_123",
"target_url": "https://yourapp.com/hooks/terac",
"event_types": ["submission.status.change", "submission.approved"],
"is_enabled": true,
"confirmed_at": null,
"disabled_at": null,
"disabled_reason": null,
"secret": "whsec_9f8e7d...",
"created_at": "2026-08-11T21:00:00.000Z",
"updated_at": "2026-08-11T21:00:00.000Z"
}Terac generates the secret; you cannot supply your own. It is returned here and nowhere else
in a create response, but GET /hooks/subscriptions/{id}/secret reads it back in full, so
losing it does not force a rotation. Treat it like an API key.
target_url must be https and must resolve to a public host: private, loopback,
link-local and internal addresses are refused, because confirming makes Terac issue a request
to it. One URL can be subscribed once per organization: a second POST for a URL you already
registered returns 409 naming the existing subscription. A new subscription comes back with
"confirmed_at": null and receives nothing until you confirm it.
2. Confirm it
curl -X POST https://terac.com/api/external/v2/hooks/subscriptions/whs_123 \
-H "Authorization: Bearer $TERAC_API_KEY" \
-H "Content-Type: application/json" \
-d '{}'Terac POSTs one signed webhook.ping to your target_url. Answer 2xx and the
subscription starts receiving events; anything else returns 412 with the reason, and
nothing is confirmed.
This is also how you test a receiver end to end. The ping carries the same signature headers as a real delivery, so verifying it proves your verification code works. Send it as often as you like.
Note the -d '{}': every POST on this API expects a JSON body and a Content-Type, even
when the endpoint takes only a path parameter. A bodyless POST returns 415.
Verifying a delivery
Every request carries four headers:
| Header | Meaning |
|---|---|
X-Terac-Request-Signature | base64(HMAC-SHA256(secret, timestamp + raw body)) |
X-Terac-Request-Timestamp | Unix seconds. Part of the signed string, so it changes on each retry. |
X-Event-ID | Unique per delivery and stable across retries. Deduplicate on this. |
X-Timestamp | ISO-8601, when the event happened. Stable across retries. Order by this. |
Sign the concatenation of the timestamp header and the raw request body, with no separator. Parsing and re-serializing the JSON first will change the bytes and the signature will not match.
import { createHmac, timingSafeEqual } from "node:crypto";
function verify(rawBody: string, headers: Headers, secret: string): boolean {
const timestamp = headers.get("X-Terac-Request-Timestamp");
const signature = headers.get("X-Terac-Request-Signature");
if (!timestamp || !signature) return false;
// Reject anything too old to be a live delivery, so a captured request
// cannot be replayed indefinitely.
const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
if (ageSeconds > 300) return false;
const expected = createHmac("sha256", secret)
.update(timestamp + rawBody)
.digest("base64");
const a = Buffer.from(expected, "base64");
const b = Buffer.from(signature, "base64");
// timingSafeEqual throws when the lengths differ, which a malformed
// signature produces.
return a.length === b.length && timingSafeEqual(a, b);
}One secret verifies every event type the subscription carries, so this function does not need to know which event it is looking at.
Rotating the secret
curl -X POST https://terac.com/api/external/v2/hooks/subscriptions/whs_123/secret \
-H "Authorization: Bearer $TERAC_API_KEY" \
-H "Content-Type: application/json" \
-d '{}'The response carries the new secret, and it takes effect immediately with no overlap window: the next attempt of every delivery, including one already queued, is signed with it. Deploy the new secret to your receiver first, or accept the signature failures in between. Rotation affects only this subscription.
Event types
Read GET /hooks/event-types rather than hardcoding a list. New event types are added
without a breaking change and appear there first.
To change what an existing subscription receives, PATCH its event_types. The list is
replaced rather than added to, so send the full set you want:
curl -X PATCH https://terac.com/api/external/v2/hooks/subscriptions/whs_123 \
-H "Authorization: Bearer $TERAC_API_KEY" \
-H "Content-Type: application/json" \
-d '{"event_types": ["submission.status.change"]}'submission.status.change
Every status transition, so one subscription follows the whole lifecycle.
{
"event_type": "submission.status.change",
"event_id": "dlv_7h3k9",
"resource_id": "sub_abc123",
"occurred_at": "2026-08-11T21:00:00.000Z",
"opportunity_id": "fy8auvdlx7ei5y5jdiy7w35z",
"from": "screening",
"to": "screened_out"
}to uses the status vocabulary of GET /submissions/{id}, so you can always fetch the
submission the event names.
from uses that vocabulary plus two names for the phase before the work starts, which a
submission has no readable status in:
from | Meaning |
|---|---|
init | Nothing answered yet. The applicant entered and had not started the screener. |
screening | Mid-screener: answering it, or answered and awaiting the outcome. |
So from tells you things to cannot. "from": "init", "to": "screened_out" is an
applicant filtered out on their profile before ever seeing the screener, while
"from": "screening", "to": "screened_out" is one who answered it and did not qualify. The
same split separates a drop-out who never started from one who abandoned the screener
half-way.
A submission is not fetchable while it is in either of those two: a half-finished screener is
not readable through the API, the way it is not openable in the dashboard. That is why they
name a from and never a to — you do not receive an event for entering the screener, only
for leaving it.
To find out why someone was screened out, read screening_answers on the submission: each
answer carries the per-question verdict. For the rates rather than the individuals, read
screening_stats on the opportunity.
submission.approved
The same transition as submission.status.change with "to": "approved", on its own event
type. Identical payload shape. Take this one alone if you only act on approvals: a submission
produces roughly five status changes, so filtering on your side means receiving five
deliveries to act on one. Taking both means an approval arrives twice, once under each event
type, with a different X-Event-ID.
Retries
A delivery that does not return 2xx is retried 12 times after the first attempt, with
the first retry a minute later and exponential backoff up to 12 hours: about two and a half
days of trying in total.
5xx, 408 and 429 are retried. Any other 4xx is treated as a deliberate rejection and
not retried, since a dozen identical requests would be refused identically. 410 Gone is the
clearest way to say "stop".
Redirects are never followed. The signature is bound to your host, so a 3xx elsewhere would
hand it to whoever controls that host. Point target_url at the final URL.
Deliveries time out after 10 seconds. Acknowledge with a 2xx first and do your work
afterwards, rather than holding the connection open.
When a subscription stops
A subscription that fails 100% of the time for five consecutive days is disabled automatically. Someone from Terac reaches out on day three first.
A subscription disabled this way comes back with is_enabled: false and a
disabled_reason, which is how you tell it apart from one you turned off yourself. Re-enable
it once the endpoint is healthy:
curl -X PATCH https://terac.com/api/external/v2/hooks/subscriptions/whs_123 \
-H "Authorization: Bearer $TERAC_API_KEY" \
-H "Content-Type: application/json" \
-d '{"is_enabled": true}'Changing target_url clears the confirmation, since the new host has not accepted a ping
yet. Confirm again to resume. Changing event_types does not: that host has already accepted
one.
The delivery log
GET /hooks/events lists deliveries, newest first, with ?subscription_id= to narrow and
cursor pagination. One row per delivery, updated in place across retries, so id is the
X-Event-ID your endpoint saw and attempt_count is how many tries it took.
Confirmation pings are not logged there: they are a handshake, not a delivery.
Handling duplicates
Deduplicate on X-Event-ID. It is stable across retries, so the same value arriving twice
means you already have that event and can answer 2xx without reprocessing. Terac will not
deliver a payload that has already been acknowledged, but a network failure after your
endpoint committed and before the response reached us looks identical to a failure from our
side, and that is the case the header exists for.