API
Offers and campaigns
Read a brand’s offers, then create a campaign with your own dates and code limit.
Endpoints
/offersEvery offer you may distribute. Filters: brandId, state./offers/{baseGs1}One offer./campaignsYour campaigns, newest first. Filters: status, brandId, externalReference./campaigns/{id}One campaign./campaignsCreate a draft. Returns 201./campaigns/{id}Change only what you send./campaigns/{id}/statusActivate, pause or end./campaigns/{id}/reportIts funnel over a range. See Reports.Offers
An offer’s state is one of available, not_yet_started, expired, temporarily_unavailable, not_distributable. Check canCreateCampaign before you create a campaign. It is true for available offers and for not_yet_started offers that will be ready once they start. A campaign on an offer that hasn’t started hands out codes from the offer’s first day.
{
"baseGs1": "8112000000000000000",
"brand": { "id": "9a2b835d-674e-48ab-b012-a93b6407c2e8", "name": "Example Brand" },
"title": "$1 off any two", "description": "Save on two qualifying products.",
"state": "available", "canDistribute": true, "canCreateCampaign": true,
"savings": { "cents": 100, "display": "$1.00" },
"productBarcodes": ["012345678905"],
"campaignDates": { "from": "2026-09-01", "through": "2026-09-30" },
"redemptionDates": { "from": "2026-09-01", "through": "2026-10-31" },
"codesAvailable": 10000,
"retailerChoiceRequired": false, "allowedRetailers": ["example.com"],
"lastUpdatedAt": "2026-09-11T15:04:05Z"
}| Field | What it is |
|---|---|
baseGs1 / brand | Offer identifier string and brand { id, name }. Keep baseGs1 as text; brand.id is the brandId filter value. |
title / description | Strings, or null if the brand did not supply them. |
state / canDistribute | State string above, and a boolean saying whether campaigns can hand out codes for the offer right now. |
canCreateCampaign | Boolean saying whether you can create a campaign on the offer or raise a campaign’s code limit. Also true before the offer starts. |
savings | Object { cents: integer, display: string }, or null if no shopper savings amount is available. cents is not dollars. |
productBarcodes | Array of product barcode strings; empty when none were supplied. Preserve leading zeros. |
lastUpdatedAt | UTC timestamp when Viffy last refreshed the offer, even if its details stayed the same. Reading an offer does not trigger a refresh. |
codesAvailable | Integer or null if unknown. Codes left on the offer, shared across campaigns and other distributors. Checked live when you create or raise the limit; creating a campaign does not reserve that supply. |
allowedRetailers | The retailers a campaign may restrict to, as email domains. Empty means no campaign-specific retailer restriction may be selected; shoppers still need a participating retailer that accepts the offer. |
retailerChoiceRequired | True where every campaign on the offer must name a retailer. |
campaignDates / redemptionDates | Each is { from, through } in calendar days, or null when unavailable. campaignDates are the days codes can be handed out. Campaign dates you choose must fit inside both: they can’t start before either from or end after either through. |
Create a campaign
POST /campaigns
{
"externalReference": "fall-launch-2026",
"name": "Fall launch",
"baseGs1": "8112000000000000000",
"dates": { "from": "2026-09-01", "through": "2026-09-30" },
"codeLimit": 5000,
"retailer": { "emailDomain": "example.com", "name": "Example Market" }
}{
"id": "7c1e29a4-5b68-4d02-91f3-8a4c6e0b752d",
"externalReference": "fall-launch-2026",
"name": "Fall launch",
"status": "draft",
"offer": { "baseGs1": "8112000000000000000", "title": "$1 off any two", "brand": { "id": "9a2b835d-674e-48ab-b012-a93b6407c2e8", "name": "Example Brand" } },
"dates": { "from": "2026-09-01", "through": "2026-09-30" },
"retailer": { "emailDomain": "example.com", "name": "Example Market" },
"codes": { "limit": 5000, "issued": 0, "redeemed": 0, "testCodes": 0, "remaining": 5000 },
"allowedStatuses": ["active", "ended"],
"canChangeRetailer": true,
"createdAt": "2026-08-28T15:04:05Z",
"updatedAt": "2026-08-28T15:04:05Z"
}| Field | Rule |
|---|---|
name | Required. One line, up to 120 characters. |
baseGs1 | Required. An offer from GET /offers. |
dates | Required. Calendar days within the offer’s redemptionDates and campaignDates: from no earlier than either from, through no later than either through, and through on or after from. An edit that keeps a campaign’s saved through date isn’t refused if the offer’s campaignDates.through has since moved earlier. |
codeLimit | Required integer, 1–10,000,000, also bounded by the offer’s available codes. |
retailer | Optional unless retailerChoiceRequired is true. Both emailDomain (one of allowedRetailers) and name, together. |
externalReference | Optional, up to 120 characters and unique in your organization. Set it on creation; PATCH cannot change it. |
Send an externalReference so you can find the campaign after a timeout. A repeated create with that reference returns 409; use GET /campaigns?externalReference= to read the existing campaign.
Change a campaign
Send only the fields to change. Absent means unchanged.
PATCH /campaigns/{id}
{ "codeLimit": 8000, "retailer": null }codeLimitis the new total. You can raise it before a campaign ends while the offer’scanCreateCampaignis true and it has codes left.- You can lower the limit to the number already handed out, including test codes.
retailer: nullclears the restriction; an object with bothemailDomainandnamereplaces it. The retailer identifier becomes fixed when the first code is created, shown bycanChangeRetailer: false.- To correct the retailer’s display name after that, send the same
emailDomainwith the new name. datesmerges per field. Omit a nested field or send null to keep its current value.- Sending null for
name,dates, orcodeLimitleaves that field unchanged. - Set
baseGs1andexternalReferencewhen creating the campaign. PATCH does not change them.
Campaign response fields
GET, PATCH, and status changes return the campaign object shown above. Lists wrap these objects in a page, using page and pageSize.
| Field | What it is |
|---|---|
id / externalReference / name / status | Viffy ID, nullable partner reference, display name, and draft, active, paused, or ended status. |
offer | Object { baseGs1, title, brand: { id, name } }; title can be null. |
dates | Object { from, through }, both inclusive calendar days. |
retailer | Object { emailDomain, name }, or null when unrestricted. name can be null on a read. |
codes | Integer limit, issued (shoppers), redeemed (verified), testCodes, and remaining. limit = issued + testCodes + remaining; counts are all-time, not a report date range. |
allowedStatuses / canChangeRetailer | Array of next status strings and boolean saying whether the retailer identifier can still change. |
createdAt / updatedAt | UTC timestamps when the campaign was created and last changed. |
Remaining codes describe the limit, not immediate availability. For the full response returned by a campaign’s report, see Reports.
Change status
POST /campaigns/{id}/status
{ "status": "active" }Activate a draft, then pause or resume it as needed. You can end a draft, active, or paused campaign; an ended campaign cannot restart.
allowedStatuses lists the campaign’s next available statuses. Sending its current status returns 200 without changing it.
Paused or ended campaigns
No new codes are handed out. Codes already handed out still work and still count for their creator.If your organization no longer has access to a campaign’s brand, the campaign is view-only. Its allowedStatuses is empty and every change returns 409 conflict, while reads, reports, and exports keep working.