API
Webhook reference
Look up event fields, delivery rules, signature verification, and recovery steps.
Endpoints
/webhooksYour endpoints, up to 5./webhooksRegister a public HTTPS address. Returns the secret once./webhooks/{id}One endpoint with its recent deliveries./webhooks/{id}Change url, eventTypes, or active./webhooks/{id}Remove it./webhooks/{id}/rotate-secretA new secret. The old one keeps working for 24 hours./webhooks/{id}/testSend a signed sample of one event type (type)./eventsEvery event of the last 30 days, oldest first. Filters: after, type, limit./events/{id}One event./events/typesAn array of { type, description } for the eight supported types.Register an endpoint
POST /webhooks
{ "url": "https://example.com/viffy/events", "eventTypes": ["redemption.recorded", "redemption.excluded", "redemption.restored"] }{
"webhook": {
"id": "5d2c308f-7289-4d15-aab6-413c650d9e20",
"url": "https://example.com/viffy/events",
"eventTypes": ["redemption.recorded", "redemption.excluded", "redemption.restored"],
"active": true,
"status": "ok",
"failingSince": null, "lastDeliveredAt": null, "previousSecretExpiresAt": null,
"createdAt": "2026-08-28T15:04:05Z", "updatedAt": "2026-08-28T15:04:05Z"
},
"secret": "whsec_..."
}| Field | What it is |
|---|---|
url | Required HTTPS URL, up to 2,000 characters, on a publicly reachable host. No sign-in details or fragment. Local and private addresses are refused. |
eventTypes | Required array with at least one supported type, matched case-sensitively. Duplicates are removed. No wildcard; list every type you need. |
The limit is five endpoints, including paused ones. New endpoints receive future matching events; registering one does not send the existing event log.
What a delivery looks like
The signature shown here is a placeholder. A real delivery signs its original body bytes; use the verification function below.
POST https://example.com/viffy/events
Content-Type: application/json; charset=utf-8
User-Agent: Viffy-Webhooks/1
Viffy-Event-Id: 0b7f1432-584a-4c19-9ba6-000000000001
Viffy-Event-Type: redemption.recorded
Viffy-Signature: t=1789139045,v1=SIGNATURE_HEX
{
"id": "0b7f1432-584a-4c19-9ba6-000000000001",
"type": "redemption.recorded",
"occurredAt": "2026-09-11T15:04:05Z",
"organizationId": "2f6d1c2e-9c3a-4b7e-8f21-0a1b2c3d4e5f",
"sample": false,
"data": {
"id": "e3a954b1-820c-49d7-a056-cc7f2d9e613a",
"serializedGs1": "8112000000000000000000000000",
"redeemedAt": "2026-09-11T14:59:12Z",
"recordedAt": "2026-09-11T15:04:05Z",
"campaign": {
"id": "7c1e29a4-5b68-4d02-91f3-8a4c6e0b752d",
"externalReference": "fall-launch-2026",
"name": "Fall launch"
},
"offer": {
"baseGs1": "8112000000000000000",
"title": "$1 off any two"
},
"brand": {
"id": "9a2b835d-674e-48ab-b012-a93b6407c2e8",
"name": "Acme Foods"
},
"creator": {
"externalId": "hb-1042",
"name": "Jane Doe"
},
"retailer": null,
"countingState": "counted",
"exclusion": null
}
}| Field | Type | Meaning |
|---|---|---|
id | string (uuid) | Event ID. De-duplicate this value across deliveries, retries, and event-log reads. |
type | string | Event type; selects the data shape below. |
occurredAt | string (date-time) | When Viffy created the event. This can be later than the redemption or action time. |
organizationId | string (uuid) | Organization the event belongs to. |
sample | boolean | True for a test event; skip business updates and creator credit for samples. |
data | object | Snapshot described by the event type. Nullable fields are included as null. |
- Verify the signature and save the event to durable storage or a queue, then return a
2xxresponse within 10 seconds. Process it afterward; confirming receipt stops retries. - Return a response directly. Redirects count as failures and are not followed.
- The same event may arrive more than once. Use the envelope’s
idto avoid applying it twice. - Events may arrive out of order. Fetch the current resource when a correction could affect your decision.
- Samples carry
sample: true. Verify and acknowledge them, but skip creator credit and other business updates.
Verify the signature
Viffy-Signature is t=seconds,v1=hex, where v1 is HMAC-SHA256 with your secret over the string t + "." + body. Check it over the raw bytes received, compare in constant time, and reject a t more than five minutes from your clock.
import { createHmac, timingSafeEqual } from "node:crypto";
// rawBody must be a Buffer captured before your framework parses JSON.
export function verify(signatureHeader, rawBody, secret, nowSeconds = Date.now() / 1000) {
if (typeof signatureHeader !== "string" || !Buffer.isBuffer(rawBody)) return false;
if (typeof secret !== "string" || !secret) return false;
const parts = signatureHeader.split(",").map((part) => part.trim());
const timestamps = parts.filter((part) => part.startsWith("t="));
if (timestamps.length !== 1) return false;
const timestamp = timestamps[0].slice(2);
if (!/^[0-9]+$/.test(timestamp)) return false;
const t = Number(timestamp);
if (!Number.isSafeInteger(t) || Math.abs(nowSeconds - t) > 300) return false;
// Use the full whsec_... string as the UTF-8 HMAC key; do not decode it.
const expected = createHmac("sha256", secret)
.update(timestamp + ".", "utf8")
.update(rawBody)
.digest();
// Rotation sends two v1 values. Accept a match with either one.
return parts.filter((part) => part.startsWith("v1=")).some((part) => {
const hex = part.slice(3);
if (!/^[0-9a-f]{64}$/i.test(hex)) return false;
return timingSafeEqual(Buffer.from(hex, "hex"), expected);
});
}Failures and retries
After each failed attempt, Viffy waits for the interval below before trying again. There are seven attempts in total, including the first delivery.
| Retry | Wait after the previous failure |
|---|---|
| 1 | 1 minute |
| 2 | 5 minutes |
| 3 | 30 minutes |
| 4 | 2 hours |
| 5 | 8 hours |
| 6 | 24 hours |
The waits total 34 hours and 36 minutes, plus delivery and scheduling time.
| Endpoint status | Meaning |
|---|---|
ok | Active with no current failure streak; this can also mean no delivery has been attempted yet. |
needs_attention | The latest delivery failed. After one hour of continuing failure, configured alert recipients are emailed. |
paused | active is false. Pending deliveries wait; new events stay in the log without being queued to this endpoint. |
Deliveries run one at a time for each endpoint, but a newer event may arrive before an older retry. A failed endpoint stays active; Viffy does not automatically pause it.
Manage an endpoint and inspect deliveries
GET /webhooks returns { items: [webhook], limit: 5, configured: true }. It is not paginated; configured: false means signing needs setup by Viffy support.
PATCH /webhooks/{id} returns the updated webhook object. Send any of url, eventTypes, or active; omitted or null fields stay unchanged, and an empty change is refused.
Resuming sends previously queued deliveries. Events created while paused, before registration, or before subscribing to a type must be recovered from the event log. Changing a URL also changes where pending deliveries go; changing event types does not cancel already queued deliveries.
DELETE /webhooks/{id} returns 204 with no body and removes its queued deliveries. Events remain available in the log until their retention period ends.
{
"webhook": {
"id": "5d2c308f-7289-4d15-aab6-413c650d9e20",
"url": "https://example.com/viffy/events",
"eventTypes": [
"redemption.recorded",
"redemption.excluded",
"redemption.restored"
],
"active": true,
"status": "needs_attention",
"failingSince": "2026-09-11T15:04:10Z",
"lastDeliveredAt": null,
"previousSecretExpiresAt": null,
"createdAt": "2026-08-28T15:04:05Z",
"updatedAt": "2026-08-28T15:04:05Z"
},
"recentDeliveries": [
{
"id": "d698ecd0-524a-41b2-99f2-d394ae1bd0e6",
"eventId": "0b7f1432-584a-4c19-9ba6-000000000001",
"eventType": "redemption.recorded",
"occurredAt": "2026-09-11T15:04:05Z",
"sample": false,
"attempts": 1,
"lastAttemptAt": "2026-09-11T15:04:10Z",
"nextAttemptAt": "2026-09-11T15:05:10Z",
"deliveredAt": null,
"failedAt": null,
"lastStatusCode": 500,
"lastFailureCategory": "rejected"
}
]
}| Field | What it is |
|---|---|
webhook.id / url / eventTypes / active / status | Endpoint ID, destination, subscribed types, whether delivery is enabled, and delivery health. |
webhook.failingSince | UTC timestamp of the first failure in the current streak; null before failures or after a successful delivery. |
webhook.lastDeliveredAt | UTC timestamp of the most recent success, or null before any success. |
webhook.previousSecretExpiresAt | UTC timestamp when the previous secret stops signing, or null before rotation. |
webhook.createdAt / updatedAt | UTC timestamps when the endpoint was registered and last edited. |
recentDeliveries | Up to 50 deliveries, newest first. This list is bounded; use the event log for replay. |
recentDeliveries[].id / eventId / eventType / occurredAt / sample | Delivery ID, event ID, type, event creation time, and sample flag. De-duplicate by eventId, not the delivery ID. |
recentDeliveries[].attempts | Integer number of attempts made, including failures to sign or connect. |
recentDeliveries[].lastAttemptAt / nextAttemptAt | UTC timestamps or null. nextAttemptAt is null after success or final failure. |
recentDeliveries[].deliveredAt / failedAt | UTC timestamps or null. failedAt marks the final failed attempt, not an individual retry. |
recentDeliveries[].lastStatusCode | Integer HTTP status, or null if no HTTP response was received. |
recentDeliveries[].lastFailureCategory | null after success, or rejected (non-2xx), redirected, timed_out, unreachable, or not_signed. Check your handler, HTTPS destination, connectivity, or contact support for signing. |
Test and rotate a secret
POST /webhooks/{id}/test
{ "type": "redemption.recorded" }The 202 response is the complete queued event envelope with sample: true; it does not mean delivery succeeded. The same event appears in the log. Read the endpoint’s recent deliveries to check the result.
You can test any supported type even if it is not subscribed. A paused endpoint queues the sample and sends it after resuming. Sample resource IDs and values are fictional and should not be used in follow-up API calls.
POST /webhooks/{id}/rotate-secret has no body and returns 200 with { webhook, secret }, like registration. Two signatures are sent for 24 hours; use the new secret, then retire the old one. A second rotation replaces the previous secret, so finish one rotation before starting another.
Recover missed events
GET /events returns events oldest first by occurredAt, then id. Omit after to start at the beginning of the retained log; limit defaults to 100 and is clamped to 1–1,000.
type optionally selects one supported type. Keep a separate cursor per organization and filter; a delivery ID or event ID is not a cursor.
{
"items": [
{
"id": "0b7f1432-584a-4c19-9ba6-000000000001",
"type": "redemption.recorded",
"occurredAt": "2026-09-11T15:04:05Z",
"organizationId": "2f6d1c2e-9c3a-4b7e-8f21-0a1b2c3d4e5f",
"sample": false,
"data": {
"id": "e3a954b1-820c-49d7-a056-cc7f2d9e613a",
"serializedGs1": "8112000000000000000000000000",
"redeemedAt": "2026-09-11T14:59:12Z",
"recordedAt": "2026-09-11T15:04:05Z",
"campaign": {
"id": "7c1e29a4-5b68-4d02-91f3-8a4c6e0b752d",
"externalReference": "fall-launch-2026",
"name": "Fall launch"
},
"offer": {
"baseGs1": "8112000000000000000",
"title": "$1 off any two"
},
"brand": {
"id": "9a2b835d-674e-48ab-b012-a93b6407c2e8",
"name": "Acme Foods"
},
"creator": {
"externalId": "hb-1042",
"name": "Jane Doe"
},
"retailer": null,
"countingState": "counted",
"exclusion": null
}
}
],
"nextCursor": null
}cursor = load_last_non_null_cursor() // keep one per organization and filter
loop:
page = GET /events?limit=1000 // add after=cursor only when you have one
handle_and_commit(page.items) // skip samples; de-duplicate by event id
if page.nextCursor is null: break // process the last page before stopping
cursor = page.nextCursor
save_cursor(cursor) // checkpoint only after handling succeeds
// Next run: reuse cursor; if none has been returned, omit after again.GET /events/{id} returns one stored envelope, without an extra wrapper. Event payloads remain as recorded; replay does not fetch current resource values or send another webhook.
For exclusions and restorations, fetch the current redemption before updating your copy. See keeping a complete copy.
Troubleshooting webhooks
| Problem | What to check |
|---|---|
| No delivery arrives | Check that the endpoint is active and subscribes to the event type. Send a sample and inspect recentDeliveries. |
| Signature check fails | Use the original body bytes, the full whsec_ secret, and an accurate server clock. Do not verify re-created JSON. |
| The delivery times out | Save or queue the verified event, respond within ten seconds, and process it afterward. |
| An event arrives twice | Check whether you already processed its event ID before applying it again. |
| Events are missing after a pause | Read the event log for the paused period. Resuming only sends deliveries that were already queued. |
| Supply alerts do not arrive | Configure alert email recipients in Settings. Supply events are created after the alert email succeeds. |
| The sample arrived but figures did not change | Samples test delivery only. They should not change business totals or trigger resource lookups. |
For unresolved problems, contact Viffy with the event ID and delivery result. Keep your API key and webhook secret private.
Event types and payloads
Each example is a complete event body with fictional values. All listed fields are present; fields marked nullable can be null.
| Type | When |
|---|---|
redemption.recorded | A redemption was first recorded for a code on your campaign. |
redemption.excluded | Your team excluded a redemption from the counts. |
redemption.restored | Your team restored an excluded redemption to the counts. |
code.issued | A shopper received a new code through a creator’s link. |
campaign.status_changed | A campaign changed status through Viffy or the API. |
campaign.supply_low | An active campaign’s low-code alert was sent. |
campaign.supply_exhausted | An active campaign’s no-codes-left alert was sent. |
assignment.completed | A bulk link job completed, including one with individual failures. |
redemption.recorded
A redemption was first recorded for a code on your campaign.
| Field | Type | Meaning |
|---|---|---|
id | string (uuid) | Redemption ID; distinct from the envelope event ID. |
serializedGs1 | string | The redeemed code. Keep it as text to preserve every digit. |
redeemedAt | string (date-time) | When the redemption happened. |
recordedAt | string (date-time) | When Viffy first recorded the redemption; the redemption feed sorts by this time. |
campaign | CampaignReference | Campaign reference; fields are defined below. |
offer | OfferReference | Offer identifier and title. |
brand | BrandReference | Brand identifier and name. |
creator | CreatorReference | null | Attributed creator; null only when the code reached no shopper. |
retailer | RetailerReference | null | Campaign retailer restriction, or null when unrestricted. |
countingState | string | Counting status at the time this event was created. Values: counted. |
exclusion | Exclusion | null | Null on this event. GET /redemptions/{id} can return an exclusion after a later correction. |
- The data has the same fields as GET /redemptions/{id}, captured when the event was created. Later corrections do not rewrite this event.
- Staff test redemptions do not produce this event. A counted redemption can have no creator when its code reached no shopper; reconcile creator credit with the creator-counts report.
{
"id": "0b7f1432-584a-4c19-9ba6-000000000001",
"type": "redemption.recorded",
"occurredAt": "2026-09-11T15:04:05Z",
"organizationId": "2f6d1c2e-9c3a-4b7e-8f21-0a1b2c3d4e5f",
"sample": false,
"data": {
"id": "e3a954b1-820c-49d7-a056-cc7f2d9e613a",
"serializedGs1": "8112000000000000000000000000",
"redeemedAt": "2026-09-11T14:59:12Z",
"recordedAt": "2026-09-11T15:04:05Z",
"campaign": {
"id": "7c1e29a4-5b68-4d02-91f3-8a4c6e0b752d",
"externalReference": "fall-launch-2026",
"name": "Fall launch"
},
"offer": {
"baseGs1": "8112000000000000000",
"title": "$1 off any two"
},
"brand": {
"id": "9a2b835d-674e-48ab-b012-a93b6407c2e8",
"name": "Acme Foods"
},
"creator": {
"externalId": "hb-1042",
"name": "Jane Doe"
},
"retailer": null,
"countingState": "counted",
"exclusion": null
}
}redemption.excluded
Your team excluded a redemption from the counts.
| Field | Type | Meaning |
|---|---|---|
redemptionId | string (uuid) | The redemption to refresh with GET /redemptions/{id}. |
campaign | CampaignReference | Campaign reference; fields are defined below. |
creator | CreatorReference | null | Attributed creator; null only when the code reached no shopper. |
excludedAt | string (date-time) | When the correction was made. |
reason | string | The reason your team gave for this correction. |
- This is a correction notice, not a complete redemption. Fetch the redemption by redemptionId for its current countingState; deliveries can arrive out of order.
{
"id": "0b7f1432-584a-4c19-9ba6-000000000002",
"type": "redemption.excluded",
"occurredAt": "2026-09-11T15:04:05Z",
"organizationId": "2f6d1c2e-9c3a-4b7e-8f21-0a1b2c3d4e5f",
"sample": false,
"data": {
"redemptionId": "e3a954b1-820c-49d7-a056-cc7f2d9e613a",
"campaign": {
"id": "7c1e29a4-5b68-4d02-91f3-8a4c6e0b752d",
"externalReference": "fall-launch-2026",
"name": "Fall launch"
},
"creator": {
"externalId": "hb-1042",
"name": "Jane Doe"
},
"excludedAt": "2026-09-11T15:04:05Z",
"reason": "Duplicate credit confirmed by your team."
}
}redemption.restored
Your team restored an excluded redemption to the counts.
| Field | Type | Meaning |
|---|---|---|
redemptionId | string (uuid) | The redemption to refresh with GET /redemptions/{id}. |
campaign | CampaignReference | Campaign reference; fields are defined below. |
creator | CreatorReference | null | Attributed creator; null only when the code reached no shopper. |
restoredAt | string (date-time) | When the correction was made. |
reason | string | The reason your team gave for this correction. |
- This is a correction notice, not a complete redemption. Fetch the redemption by redemptionId for its current countingState; deliveries can arrive out of order.
{
"id": "0b7f1432-584a-4c19-9ba6-000000000003",
"type": "redemption.restored",
"occurredAt": "2026-09-11T15:04:05Z",
"organizationId": "2f6d1c2e-9c3a-4b7e-8f21-0a1b2c3d4e5f",
"sample": false,
"data": {
"redemptionId": "e3a954b1-820c-49d7-a056-cc7f2d9e613a",
"campaign": {
"id": "7c1e29a4-5b68-4d02-91f3-8a4c6e0b752d",
"externalReference": "fall-launch-2026",
"name": "Fall launch"
},
"creator": {
"externalId": "hb-1042",
"name": "Jane Doe"
},
"restoredAt": "2026-09-11T15:04:05Z",
"reason": "Review confirmed this redemption should count."
}
}code.issued
A shopper received a new code through a creator’s link.
| Field | Type | Meaning |
|---|---|---|
claimId | string (uuid) | Opaque claim reference for correlation. No partner endpoint accepts it. |
campaign | CampaignReference | Campaign reference; fields are defined below. |
creator | CreatorReference | The creator credited when the code was handed out. |
issuedAt | string (date-time) | When the shopper received the code. |
- Recovering an existing code does not create another event. Staff test codes do not produce this event.
- No phone number, phone hash, recovery token, scannable code, or shopper identity is returned.
{
"id": "0b7f1432-584a-4c19-9ba6-000000000004",
"type": "code.issued",
"occurredAt": "2026-09-11T15:04:05Z",
"organizationId": "2f6d1c2e-9c3a-4b7e-8f21-0a1b2c3d4e5f",
"sample": false,
"data": {
"claimId": "c918d264-b302-451f-a9ec-426b87e915d0",
"campaign": {
"id": "7c1e29a4-5b68-4d02-91f3-8a4c6e0b752d",
"externalReference": "fall-launch-2026",
"name": "Fall launch"
},
"creator": {
"externalId": "hb-1042",
"name": "Jane Doe"
},
"issuedAt": "2026-09-11T15:04:05Z"
}
}campaign.status_changed
A campaign changed status through Viffy or the API.
| Field | Type | Meaning |
|---|---|---|
campaign | CampaignReference | Campaign reference; fields are defined below. |
from | string | Previous campaign status. Values: draft, active, paused. |
to | string | New campaign status. Values: active, paused, ended. |
- Creating a draft and asking for the status a campaign already has do not produce this event. Campaign creation, name edits, date edits, and commission edits have no separate event.
{
"id": "0b7f1432-584a-4c19-9ba6-000000000005",
"type": "campaign.status_changed",
"occurredAt": "2026-09-11T15:04:05Z",
"organizationId": "2f6d1c2e-9c3a-4b7e-8f21-0a1b2c3d4e5f",
"sample": false,
"data": {
"campaign": {
"id": "7c1e29a4-5b68-4d02-91f3-8a4c6e0b752d",
"externalReference": "fall-launch-2026",
"name": "Fall launch"
},
"from": "draft",
"to": "active"
}
}campaign.supply_low
An active campaign’s low-code alert was sent.
| Field | Type | Meaning |
|---|---|---|
campaign | CampaignReference | Campaign reference; fields are defined below. |
codes | SupplyCodes | Code limit, all codes handed out, and the remaining limit. |
- Low means a positive remainder at or below 10% of the limit by default, rounded up; exhausted means none remains. Alerts are checked every five minutes by default.
- These events follow the supply email alerts, once per incident after email delivery succeeds. Configure alert recipients; without them, these events are not produced.
- An empty supply of ready codes has a separate email alert and no event type. A low-code event may be delayed while that condition takes priority.
{
"id": "0b7f1432-584a-4c19-9ba6-000000000006",
"type": "campaign.supply_low",
"occurredAt": "2026-09-11T15:04:05Z",
"organizationId": "2f6d1c2e-9c3a-4b7e-8f21-0a1b2c3d4e5f",
"sample": false,
"data": {
"campaign": {
"id": "7c1e29a4-5b68-4d02-91f3-8a4c6e0b752d",
"externalReference": "fall-launch-2026",
"name": "Fall launch"
},
"codes": {
"limit": 5000,
"issued": 4600,
"remaining": 400
}
}
}campaign.supply_exhausted
An active campaign’s no-codes-left alert was sent.
| Field | Type | Meaning |
|---|---|---|
campaign | CampaignReference | Campaign reference; fields are defined below. |
codes | SupplyCodes | Code limit, all codes handed out, and the remaining limit. |
- Low means a positive remainder at or below 10% of the limit by default, rounded up; exhausted means none remains. Alerts are checked every five minutes by default.
- These events follow the supply email alerts, once per incident after email delivery succeeds. Configure alert recipients; without them, these events are not produced.
- An empty supply of ready codes has a separate email alert and no event type. A low-code event may be delayed while that condition takes priority.
{
"id": "0b7f1432-584a-4c19-9ba6-000000000007",
"type": "campaign.supply_exhausted",
"occurredAt": "2026-09-11T15:04:05Z",
"organizationId": "2f6d1c2e-9c3a-4b7e-8f21-0a1b2c3d4e5f",
"sample": false,
"data": {
"campaign": {
"id": "7c1e29a4-5b68-4d02-91f3-8a4c6e0b752d",
"externalReference": "fall-launch-2026",
"name": "Fall launch"
},
"codes": {
"limit": 5000,
"issued": 5000,
"remaining": 0
}
}
}assignment.completed
A bulk link job completed, including one with individual failures.
| Field | Type | Meaning |
|---|---|---|
jobId | string (uuid) | Use with campaign.id to GET /campaigns/{campaignId}/assignments/{jobId}. |
campaign | CampaignReference | Campaign reference; fields are defined below. |
assigned | integer | Number of links assigned by the job. |
failed | integer | Number of selected creators the job could not give a link to. |
- The event carries counts, not links or the failed creator IDs. Read the job for unassignableExternalIds and the campaign’s creators for links.
- A job whose status becomes failed instead of completed does not produce this event. Poll the job until its status is completed or failed.
{
"id": "0b7f1432-584a-4c19-9ba6-000000000008",
"type": "assignment.completed",
"occurredAt": "2026-09-11T15:04:05Z",
"organizationId": "2f6d1c2e-9c3a-4b7e-8f21-0a1b2c3d4e5f",
"sample": false,
"data": {
"jobId": "b41d827c-e09a-4a7e-b068-a957c36e120f",
"campaign": {
"id": "7c1e29a4-5b68-4d02-91f3-8a4c6e0b752d",
"externalReference": "fall-launch-2026",
"name": "Fall launch"
},
"assigned": 120,
"failed": 2
}
}