Skip to content
Browse the docs

API

Webhook reference

Look up event fields, delivery rules, signature verification, and recovery steps.

Setting up your first webhook? Start with Set up webhooks, then return here for the details.

Endpoints

GET/webhooksYour endpoints, up to 5.
POST/webhooksRegister a public HTTPS address. Returns the secret once.
GET/webhooks/{id}One endpoint with its recent deliveries.
PATCH/webhooks/{id}Change url, eventTypes, or active.
DELETE/webhooks/{id}Remove it.
POST/webhooks/{id}/rotate-secretA new secret. The old one keeps working for 24 hours.
POST/webhooks/{id}/testSend a signed sample of one event type (type).
GET/eventsEvery event of the last 30 days, oldest first. Filters: after, type, limit.
GET/events/{id}One event.
GET/events/typesAn array of { type, description } for the eight supported types.

Register an endpoint

Request
POST /webhooks
{ "url": "https://example.com/viffy/events", "eventTypes": ["redemption.recorded", "redemption.excluded", "redemption.restored"] }
201 response
{
  "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_..."
}
The secret is shown once, here and on rotation. Store the full value on your server.
FieldWhat it is
urlRequired HTTPS URL, up to 2,000 characters, on a publicly reachable host. No sign-in details or fragment. Local and private addresses are refused.
eventTypesRequired 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.

Delivery
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
  }
}
FieldTypeMeaning
idstring (uuid)Event ID. De-duplicate this value across deliveries, retries, and event-log reads.
typestringEvent type; selects the data shape below.
occurredAtstring (date-time)When Viffy created the event. This can be later than the redemption or action time.
organizationIdstring (uuid)Organization the event belongs to.
samplebooleanTrue for a test event; skip business updates and creator credit for samples.
dataobjectSnapshot 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 2xx response 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 id to 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.

Node.js
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.

RetryWait after the previous failure
11 minute
25 minutes
330 minutes
42 hours
58 hours
624 hours

The waits total 34 hours and 36 minutes, plus delivery and scheduling time.

Endpoint statusMeaning
okActive with no current failure streak; this can also mean no delivery has been attempted yet.
needs_attentionThe latest delivery failed. After one hour of continuing failure, configured alert recipients are emailed.
pausedactive 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.

GET /webhooks/{id}: 200 response
{
  "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"
    }
  ]
}
FieldWhat it is
webhook.id / url / eventTypes / active / statusEndpoint ID, destination, subscribed types, whether delivery is enabled, and delivery health.
webhook.failingSinceUTC timestamp of the first failure in the current streak; null before failures or after a successful delivery.
webhook.lastDeliveredAtUTC timestamp of the most recent success, or null before any success.
webhook.previousSecretExpiresAtUTC timestamp when the previous secret stops signing, or null before rotation.
webhook.createdAt / updatedAtUTC timestamps when the endpoint was registered and last edited.
recentDeliveriesUp to 50 deliveries, newest first. This list is bounded; use the event log for replay.
recentDeliveries[].id / eventId / eventType / occurredAt / sampleDelivery ID, event ID, type, event creation time, and sample flag. De-duplicate by eventId, not the delivery ID.
recentDeliveries[].attemptsInteger number of attempts made, including failures to sign or connect.
recentDeliveries[].lastAttemptAt / nextAttemptAtUTC timestamps or null. nextAttemptAt is null after success or final failure.
recentDeliveries[].deliveredAt / failedAtUTC timestamps or null. failedAt marks the final failed attempt, not an individual retry.
recentDeliveries[].lastStatusCodeInteger HTTP status, or null if no HTTP response was received.
recentDeliveries[].lastFailureCategorynull 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

Send a sample
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.

GET /events?type=redemption.recorded: final page
{
  "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
}
Replay loop
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.

Events are retained for 30 days. A cursor older than that does not restore deleted history or report the gap. After a longer interruption, rebuild from resource reads and reports; old code-issued and status-change history cannot be recovered from this log.

For exclusions and restorations, fetch the current redemption before updating your copy. See keeping a complete copy.

Troubleshooting webhooks

ProblemWhat to check
No delivery arrivesCheck that the endpoint is active and subscribes to the event type. Send a sample and inspect recentDeliveries.
Signature check failsUse the original body bytes, the full whsec_ secret, and an accurate server clock. Do not verify re-created JSON.
The delivery times outSave or queue the verified event, respond within ten seconds, and process it afterward.
An event arrives twiceCheck whether you already processed its event ID before applying it again.
Events are missing after a pauseRead the event log for the paused period. Resuming only sends deliveries that were already queued.
Supply alerts do not arriveConfigure alert email recipients in Settings. Supply events are created after the alert email succeeds.
The sample arrived but figures did not changeSamples 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.

TypeWhen
redemption.recordedA redemption was first recorded for a code on your campaign.
redemption.excludedYour team excluded a redemption from the counts.
redemption.restoredYour team restored an excluded redemption to the counts.
code.issuedA shopper received a new code through a creator’s link.
campaign.status_changedA campaign changed status through Viffy or the API.
campaign.supply_lowAn active campaign’s low-code alert was sent.
campaign.supply_exhaustedAn active campaign’s no-codes-left alert was sent.
assignment.completedA bulk link job completed, including one with individual failures.

redemption.recorded

A redemption was first recorded for a code on your campaign.

FieldTypeMeaning
idstring (uuid)Redemption ID; distinct from the envelope event ID.
serializedGs1stringThe redeemed code. Keep it as text to preserve every digit.
redeemedAtstring (date-time)When the redemption happened.
recordedAtstring (date-time)When Viffy first recorded the redemption; the redemption feed sorts by this time.
campaignCampaignReferenceCampaign reference; fields are defined below.
offerOfferReferenceOffer identifier and title.
brandBrandReferenceBrand identifier and name.
creatorCreatorReference | nullAttributed creator; null only when the code reached no shopper.
retailerRetailerReference | nullCampaign retailer restriction, or null when unrestricted.
countingStatestringCounting status at the time this event was created. Values: counted.
exclusionExclusion | nullNull 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.
Complete event body
{
  "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.

FieldTypeMeaning
redemptionIdstring (uuid)The redemption to refresh with GET /redemptions/{id}.
campaignCampaignReferenceCampaign reference; fields are defined below.
creatorCreatorReference | nullAttributed creator; null only when the code reached no shopper.
excludedAtstring (date-time)When the correction was made.
reasonstringThe 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.
Complete event body
{
  "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.

FieldTypeMeaning
redemptionIdstring (uuid)The redemption to refresh with GET /redemptions/{id}.
campaignCampaignReferenceCampaign reference; fields are defined below.
creatorCreatorReference | nullAttributed creator; null only when the code reached no shopper.
restoredAtstring (date-time)When the correction was made.
reasonstringThe 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.
Complete event body
{
  "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.

FieldTypeMeaning
claimIdstring (uuid)Opaque claim reference for correlation. No partner endpoint accepts it.
campaignCampaignReferenceCampaign reference; fields are defined below.
creatorCreatorReferenceThe creator credited when the code was handed out.
issuedAtstring (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.
Complete event body
{
  "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.

FieldTypeMeaning
campaignCampaignReferenceCampaign reference; fields are defined below.
fromstringPrevious campaign status. Values: draft, active, paused.
tostringNew 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.
Complete event body
{
  "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.

FieldTypeMeaning
campaignCampaignReferenceCampaign reference; fields are defined below.
codesSupplyCodesCode 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.
Complete event body
{
  "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.

FieldTypeMeaning
campaignCampaignReferenceCampaign reference; fields are defined below.
codesSupplyCodesCode 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.
Complete event body
{
  "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.

FieldTypeMeaning
jobIdstring (uuid)Use with campaign.id to GET /campaigns/{campaignId}/assignments/{jobId}.
campaignCampaignReferenceCampaign reference; fields are defined below.
assignedintegerNumber of links assigned by the job.
failedintegerNumber 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.
Complete event body
{
  "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
  }
}

Shared objects

CampaignReference

The campaign at the time of the event.

FieldTypeMeaning
idstring (uuid)Viffy campaign ID.
externalReferencestring | nullYour campaign reference, or null when none was supplied.
namestringCampaign name.

CreatorReference

The creator whose link handed out the code.

FieldTypeMeaning
externalIdstringYour creator ID.
namestringCreator name.

OfferReference

The offer the campaign distributes.

FieldTypeMeaning
baseGs1stringOffer identifier from GET /offers; keep it as text.
titlestring | nullOffer title, or null when the brand has not supplied one.

BrandReference

The brand behind the offer.

FieldTypeMeaning
idstring (uuid)Viffy brand ID.
namestringBrand name.

RetailerReference

The chain the campaign restricts redemption to; this does not identify the store where the purchase happened.

FieldTypeMeaning
emailDomainstringRetailer identifier selected on the campaign.
namestring | nullRetailer display name, or null when not set.

Exclusion

The current exclusion when reading a redemption resource.

FieldTypeMeaning
excludedAtstring (date-time)When your team excluded the redemption.
reasonstring | nullThe reason given, or null when not set.

SupplyCodes

Supply event counts. These differ from the codes object on a campaign response.

FieldTypeMeaning
limitintegerTotal code limit.
issuedintegerAll codes handed out, including staff test codes.
remainingintegerCodes left under the limit: limit minus issued. This is not the number ready to hand out immediately.