Outbound webhooks
Being told when something changes, how to verify the signature, and how to recover the events you missed.
5 min read
On this page
Register an endpoint and Evident POSTs to it when something happens, rather than you polling for it.
Webhooks are managed through the API today — there is no settings screen for them yet. Register one with an API key:
curl -X POST https://api.evidentugc.com/api/v1/outbound-webhooks \
-H "Authorization: Bearer evnt_..." \
-H "x-store-env-id: YOUR_STORE_ENV_ID" \
-H "Content-Type: application/json" \
-d '{ "url": "https://example.com/hooks/evident", "events": ["review.approved"], "label": "Review sync" }'
The response includes the webhook’s signing secret; store it with your other secrets. GET, PUT /:id and DELETE /:id on the same path list, change and remove webhooks.
Events
| Event | Fires when |
|---|---|
review.created | A review is submitted |
review.approved | A review is published |
review.rejected | A review is rejected |
review.updated | A review is edited |
review.video_ready | An attached video finishes transcoding and is playable |
order.placed | An order is first seen |
order.completed | An order reaches a completed or shipped status |
gallery.submitted | A customer submits a photo to a gallery |
gallery.approved | A merchant publishes a submitted item |
redemption.created | A customer spends points |
redemption.used | A redemption is used |
redemption.expired | A redemption lapses unused |
Subscribe per event. A webhook subscribes to the events you choose, not to everything.
Two of these are worth calling out:
review.video_readyexists becausereview.createdcan arrive while the video is still transcoding. If you re-render a review with playback, listen for this rather than retrying onreview.created.redemption.createdcarries the discount code and afulfillment_status. On a headless store, or for any reward the platform cannot express, that arrives asunsupported— your signal that you must issue the discount yourself.
The payload
{
"event_id": "6b1e...",
"event": "review.approved",
"timestamp": "2026-09-02T10:14:22.000Z",
"data": { }
}
event_id is stable per logical event. Deduplicate on it.
Headers
| Header | Contents |
|---|---|
x-evident-signature | HMAC-SHA256 of the raw body, hex, keyed with your webhook secret |
x-evident-event | The event name, so you can route before parsing |
x-evident-webhook-id | Unique per HTTP attempt — not the same as event_id |
Verifying the signature
Compute the HMAC over the raw request body, before any JSON parsing. Frameworks that parse and re-serialize will change the bytes and break verification.
import crypto from 'node:crypto';
function verify(rawBody, signature, secret) {
if (typeof signature !== 'string') return false;
const expected = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
const a = Buffer.from(expected, 'utf8');
const b = Buffer.from(signature, 'utf8');
// timingSafeEqual throws on unequal lengths. The length is not secret; the digest is.
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
Compare in constant time, as above. A plain === leaks timing information. The length check matters too: without it, a request with a missing or truncated signature crashes your handler instead of being rejected.
Reject anything that does not verify. The endpoint is public by necessity, and the signature is the only thing separating a real event from anyone who guesses your URL.
Replays
The signature covers the body only; no timestamp is signed separately. That means a delivery someone has captured — from a logging proxy, say — will still verify if they send it again. Until that changes, protect yourself on the receiving side:
- Deduplicate on
event_id. This is your main defence against replays today, not just against double deliveries. - Reject stale events. The payload’s
timestampis inside the signed body, so it cannot be altered; refuse anything older than you would ever expect a live delivery to be (five minutes is reasonable). Use the event log, not a webhook, to catch up on anything older.
Rotating the secret
There is no rotate endpoint. To replace a secret, register a second webhook for the same URL and events, accept either secret while you deploy the new one, then delete the old webhook. Expect each event twice during the overlap — deduplicating on event_id absorbs it.
Where requests come from
Deliveries come from Evident’s API servers, which do not have fixed IP addresses. Do not build an IP allow-list; it will break without warning. The signature is the check.
Delivery, honestly
Delivery is fire-and-forget with a 15-second timeout. There is no automatic retry queue today. A non-2xx response or a timeout is recorded against the webhook — you can see the last error and last fired time on the subscription — but it is not re-attempted.
So: do not treat webhooks as your only path. Acknowledge fast (queue the work, return 200 immediately) and reconcile periodically.
Recovering what you missed
There is an event log for exactly this: GET /api/v1/outbound-webhooks/events. It replays a time-ordered stream of webhook-shaped events for a store, reconstructed from the underlying rows rather than from delivery attempts — so it covers events that were never delivered at all, including ones from before your subscription existed.
It is cursor-paginated: pass a since timestamp (exclusive) and optionally a limit (1–1000, default 100). The response is { "events": [...], "next_cursor" }; while next_cursor is not null, pass it back as since. Events are ordered by timestamp then ID, so the stream is stable when several share a timestamp.
A daily reconciliation against this log is a few lines of code and removes an entire class of “we missed one and never noticed”:
async function reconcile(since, handle) {
let cursor = since; // the last timestamp you finished processing
do {
const url = new URL('https://api.evidentugc.com/api/v1/outbound-webhooks/events');
url.searchParams.set('since', cursor);
url.searchParams.set('limit', '500');
const res = await fetch(url, {
headers: {
Authorization: `Bearer ${process.env.EVIDENT_API_KEY}`,
'x-store-env-id': process.env.EVIDENT_STORE_ENV_ID,
},
});
if (!res.ok) throw new Error(`Event log returned ${res.status}`);
const { events, next_cursor } = await res.json();
for (const event of events) await handle(event); // same handler as your webhook, deduped on event_id
cursor = next_cursor;
} while (cursor);
}
Store the timestamp of the last event you processed and start from it next time.
Building the receiver
- Verify first, before parsing or acting.
- Return 200 quickly. Do the work asynchronously; there is a 15-second timeout.
- Deduplicate on
event_id. - Handle unknown events. More get added; do not throw on one you do not recognise.
- Reconcile from the event log on a schedule.
Something missing or out of date? Email support@evidentugc.com — docs corrections go straight to the team that builds the feature.