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:

HeaderMeaning
X-Terac-Request-Signaturebase64(HMAC-SHA256(secret, timestamp + raw body))
X-Terac-Request-TimestampUnix seconds. Part of the signed string, so it changes on each retry.
X-Event-IDUnique per delivery and stable across retries. Deduplicate on this.
X-TimestampISO-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:

fromMeaning
initNothing answered yet. The applicant entered and had not started the screener.
screeningMid-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.