Skip to content
Browse the docs

API

Offers and campaigns

Read a brand’s offers, then create a campaign with your own dates, code limit, and commission settings.

Endpoints

GET/offersEvery offer you may distribute. Filters: brandId, state.
GET/offers/{baseGs1}One offer.
GET/campaignsYour campaigns, newest first. Filters: status, brandId, externalReference.
GET/campaigns/{id}One campaign.
POST/campaignsCreate a draft. Returns 201.
PATCH/campaigns/{id}Change only what you send.
POST/campaigns/{id}/statusActivate, pause or end.
GET/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. Only available offers (canDistribute: true) accept a new campaign.

GET /offers/{baseGs1}: 200 response
{
  "baseGs1": "8112000000000000000",
  "brand": { "id": "9a2b835d-674e-48ab-b012-a93b6407c2e8", "name": "Acme Foods" },
  "title": "$1 off any two", "description": "Save on two qualifying products.",
  "state": "available", "canDistribute": 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": ["kroger.com"],
  "lastUpdatedAt": "2026-09-11T15:04:05Z"
}
FieldWhat it is
baseGs1 / brandOffer identifier string and brand { id, name }. Keep baseGs1 as text; brand.id is the brandId filter value.
title / descriptionStrings, or null if the brand did not supply them.
state / canDistributeState string above and boolean saying whether a new campaign can be created.
savingsObject { cents: integer, display: string }, or null if no shopper savings amount is available. cents is not dollars.
productBarcodesArray of product barcode strings; empty when none were supplied. Preserve leading zeros.
lastUpdatedAtUTC timestamp when Viffy last refreshed the offer, even if its details stayed the same. Reading an offer does not trigger a refresh.
codesAvailableInteger 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.
allowedRetailersThe 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.
retailerChoiceRequiredTrue where every campaign on the offer must name a retailer.
campaignDates / redemptionDatesEach is { from, through } in calendar days, or null when unavailable. Campaign dates you choose must fit inside redemptionDates.

Create a campaign

Request
POST /campaigns
{
  "externalReference": "fall-launch-2026",
  "name": "Fall launch",
  "baseGs1": "8112000000000000000",
  "dates": { "from": "2026-09-01", "through": "2026-09-30" },
  "codeLimit": 5000,
  "retailer": { "emailDomain": "kroger.com", "name": "Kroger" },
  "commission": { "rate": 0.1, "estimatedUnitSalePrice": 4.99 }
}
201 response
{
  "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": "Acme Foods" } },
  "dates": { "from": "2026-09-01", "through": "2026-09-30" },
  "retailer": { "emailDomain": "kroger.com", "name": "Kroger" },
  "commission": { "rate": 0.1, "estimatedUnitSalePrice": 4.99, "estimatedPerRedemption": 0.499 },
  "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"
}
FieldRule
nameRequired. One line, up to 120 characters.
baseGs1Required. An offer from GET /offers.
datesRequired. Calendar days within the offer’s redemptionDates; through is on or after from.
codeLimitRequired integer, 1–10,000,000, also bounded by the offer’s available codes.
retailerOptional unless retailerChoiceRequired is true. Both emailDomain (one of allowedRetailers) and name, together.
commissionRequired. rate between 0 and 1 (up to 4 decimals); estimatedUnitSalePrice 0 to 100,000 (2 decimals).
externalReferenceOptional, 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.

Request
PATCH /campaigns/{id}
{ "codeLimit": 8000, "retailer": null }
  • codeLimit is the new total. You can raise it before a campaign ends while the offer is available and has codes left.
  • You can lower the limit to the number already handed out, including test codes.
  • retailer: null clears the restriction; an object with both emailDomain and name replaces it. The retailer identifier becomes fixed when the first code is prepared, shown by canChangeRetailer: false.
  • To correct the retailer’s display name after that, send the same emailDomain with the new name.
  • dates and commission merge per field. Omit a nested field or send null to keep its current value.
  • Sending null for name, dates, codeLimit, or commission leaves that field unchanged.
  • Set baseGs1 and externalReference when 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.

FieldWhat it is
id / externalReference / name / statusViffy ID, nullable partner reference, display name, and draft, active, paused, or ended status.
offerObject { baseGs1, title, brand: { id, name } }; title can be null.
datesObject { from, through }, both inclusive calendar days.
retailerObject { emailDomain, name }, or null when unrestricted. name can be null on a read.
commissionDecimals rate, estimatedUnitSalePrice (dollars), and estimatedPerRedemption (dollars). These are planning inputs and an estimate, not payments.
codesInteger limit, issued (shoppers), redeemed (verified), testCodes, and remaining. limit = issued + testCodes + remaining; counts are all-time, not a report date range.
allowedStatuses / canChangeRetailerArray of next status strings and boolean saying whether the retailer identifier can still change.
createdAt / updatedAtUTC 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

Request
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.