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 that Terac can reach your receiver. 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. To see real submission events arrive, test your receiver in a sandbox.

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.

Test your receiver in a sandbox

A sandbox organization is a separate test organization that Terac sets up for your integration. If you do not have one, ask your Terac contact for one. Its dashboard shows a "Demo mode" banner, and nobody is recruited, contacted or paid in it. When you launch an opportunity there, Terac fills it with example submissions whose participant has already finished: screened out, approved, or awaiting your review. None of them moves by itself, so your endpoint hears nothing unless you approve or reject one.

A test submission is one that does move. You ask for it, Terac plays a fake participant through the opportunity, and every step sends the webhook a real participant's step would send, through the same delivery system: the same signature, the same retries, the same delivery log.

Before you start

The sandbox is its own organization, so it has its own API keys and its own webhook subscriptions.

  • An API key from the sandbox. Create it in the sandbox organization's settings, the way you created your first key. The examples below call it $TERAC_SANDBOX_API_KEY. The key of your production organization does not work here: it gets 404 for an opportunity in the sandbox, and 403 for one of its own.
  • A subscription in the sandbox. A subscription receives the events of its own organization only, so one you created with your production key hears nothing from the sandbox. Create and confirm one with the sandbox key, following Setting one up. It gets its own signing secret. The same target_url can be subscribed in both organizations, but a separate URL for the sandbox keeps each receiver to one secret.

1. Create a test submission

The opportunity has to be active, and the API key has to belong to your sandbox organization.

curl -X POST https://terac.com/api/external/v2/opportunities/fy8auvdlx7ei5y5jdiy7w35z/test-submissions \
  -H "Authorization: Bearer $TERAC_SANDBOX_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{}'
{
  "id": "sub_abc123",
  "opportunity_id": "fy8auvdlx7ei5y5jdiy7w35z",
  "task_url": null,
  "dashboard_url": "https://terac.com/acme-sandbox/onboarding/opportunities/fy8auvdlx7ei5y5jdiy7w35z/submissions"
}

Terac creates a participant called "Test Participant", with no email address and no phone number, and a submission for it. The participant then takes its steps a few seconds apart: it answers the screener, starts the task and finishes it.

A test submission is an ordinary submission in your sandbox:

  • It shows in the opportunity's Submissions table in the dashboard, and in the applicant queue when it is waiting there, under the name "Test Participant". You can approve, reject, invite or decline it there as well as through the API.
  • It counts in the opportunity's numbers, such as submission_stats. A sandbox opportunity stays open when its approvals reach or pass its target, so you can keep testing on it.
  • It does not count toward a quota, and a full quota does not stop it.
  • It cannot be removed. Use an opportunity you keep for testing.

2. Choose what happens

Both fields are optional.

FieldValueWhat the test participant does
screening_outcomepassed (default)Gets through the screener.
failedAnswers the screener and is screened out.
filtered_outIs screened out on its profile before the screener. Allowed even when the opportunity has no filters.
task_completionautomatic (default)Terac finishes the task.
redirectTerac starts the task and stops. You open task_url from the response, and your own completion redirect finishes the task.

The screening answers are real answers to your screening questions, graded the way a participant's are. What the submission shows afterwards depends on what you asked for:

You asked forscreening_outcome on the submissionscreening_answers
passedpassed, or review when the only answers that get through are flagged onesThe answers the participant gave.
failedfailedThe answers the participant gave.
filtered_outnullEmpty: the participant never saw the screener.

An opportunity with no screener also gives null and no answers. A participant whose answers are flagged for review waits in your applicant queue, whatever customer_screening_review is set to.

GET /submissions/{submissionId} returns 404 while the participant is still in screening, which includes waiting in your applicant queue. Read a waiting applicant from GET /opportunities/{opportunityId}/applicants.

3. What your endpoint receives

When the participant passes the screener and you review the work, which is the default:

Stepfrom → to
The participant passes the screenerscreening → screen_passed
It starts the taskscreen_passed → in_progress
It finishes the taskin_progress → awaiting_review
You call POST /submissions/{submissionId}/approveawaiting_review → approved, plus submission.approved
or POST /submissions/{submissionId}/rejectawaiting_review → rejected

Other setups and choices:

SituationWhat you receive
The task approves itself on completion: auto_approve with a task_url, or self_reportThe first two rows above, then in_progress → approved, plus submission.approved.
The opportunity has no screenerThe first webhook is init → in_progress. There is no screen_passed.
The opportunity has no tasksscreening → screen_passed, and nothing after it.
You review applicants first: customer_screening_review is manual_reviewNothing until you act. Joining your applicant queue sends no webhook, so poll GET /opportunities/{opportunityId}/applicants. POST /submissions/{submissionId}/invite sends screening → screen_passed, and the steps continue as above. POST /submissions/{submissionId}/decline sends screening → screened_out.
screening_outcome is failedscreening → screened_out
screening_outcome is filtered_outinit → screened_out
task_completion is redirectThe participant stops after screen_passed → in_progress. Your redirect to the completion callback sends the next one: in_progress → awaiting_review, or in_progress → approved when the task approves itself.

Timing

The participant takes its first step a few seconds after your request and the next ones about three seconds apart. Each webhook is queued when its step happens and delivered shortly afterwards, so allow about a minute for the three webhooks of the default flow.

The participant stops wherever the next move is yours: inviting or declining an applicant, approving or rejecting the work, or your own completion redirect. Your call sends the next webhook, as it does on a live opportunity.

After you invite an applicant, the participant still has the task to do. Terac looks for your decision every 30 seconds during the first hour after you created the test submission, and every 5 minutes after that, and the participant carries on from the next look. A test submission that is more than 24 hours old no longer moves by itself. It stays where it is, and you can create a new one.

Pausing the opportunity holds a test participant where it is, as it holds a real one. It carries on after you resume, on the same schedule. Stopping the opportunity ends it: the participant takes no further steps.

Finishing the task yourself

With task_completion set to redirect, the response carries task_url: the opportunity's task link with the test submission's IDs filled in, exactly as a participant receives it. Wait for the screen_passed → in_progress webhook, then open the link. When your tool redirects to Terac's completion callback, the task is finished and the next webhook is sent.

A redirect that arrives before the task has started does not finish it. Send it again once the screen_passed → in_progress webhook has arrived.

After the callback your browser lands on Terac's sign-in page, because nobody is signed in as the test participant. The task is finished all the same. See External Surveys & Redirect URLs for the callback and its result values.

When the request is refused

The message says which case applies.

StatusWhen
403The API key does not belong to a sandbox organization.
409The opportunity is not active. The message names the status it is in.
422The opportunity cannot produce what you asked for: failed with no screener, or with screening questions that no answer fails; passed with screening questions that no answer passes, or with no screener and no tasks; redirect when the first task has no task_url, or has one that is not a link.
422Terac could not find screening answers that give the outcome you asked for. It tries a limited number of combinations, so on a very large screener some may exist that it did not reach. Ask for a different screening_outcome.

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.