Skip to main content

Coupons API

A coupon program is the recipe a message issues personal codes from ({{ coupons.<slug>.code }}). Joryio creates each code in the connected WooCommerce, Shopify, Magento or PrestaShop store (or generates and tracks it alone when the program has no store) and recognizes the order that used it. How programs behave is described in Coupon codes.

All endpoints on this page are relative to the base URL: https://api-eu1.joryio.com/api - see API Overview.

Permissions​

PermissionAllows
campaigns:readList and read programs, the report and the connected stores
campaigns:writeCreate programs, change programs that have not issued codes yet, delete them, issue a code
settings:writeNeeded in addition to change a live program - one that has issued real codes
users:readList issued codes (codes belong to people)

The program object​

{
"id": "6c0f3c1e-8a52-4a8e-9a39-2a1d7f0b9a11",
"name": "Welcome 10%",
"slug": "welcome10",
"description": "10% off the first order",
"integrationId": "0b8d6f2a-5f43-4e0a-b9a3-7a8b2d1c4e55",
"platform": "woocommerce",
"storeName": "My Store",
"source": "personal",
"status": "active",
"rules": {
"discountType": "percent",
"amount": 10,
"currency": null,
"minimumSubtotal": 50,
"minimumQuantity": null,
"scope": { "kind": "all" },
"excludeSaleItems": false,
"usageLimit": 1,
"oncePerCustomer": true,
"combinable": false,
"expiry": { "mode": "relative", "amount": 14, "unit": "days", "endOfDay": true }
},
"rulesVersion": 1,
"codeFormat": { "length": 10, "charset": "unambiguous", "prefix": "WELCOME-", "suffix": "", "separatorEvery": null },
"fallback": { "policy": "skip", "sharedCode": null },
"reuseOpenCode": true,
"lockToPerson": false,
"live": true,
"sampleCode": "WELCOME-PREVIEW",
"storeError": null,
"stats": { "issued": 1240, "redeemed": 311, "revenue": 18420.5, "currency": "EUR" },
"createdAt": "2026-10-04T08:00:00.000Z",
"updatedAt": "2026-10-04T08:00:00.000Z"
}
FieldValues
integrationIdA connected store, or null: Joryio generates and tracks the codes without a store
statusdraft, active, paused, archived - only active issues codes
rules.discountTypepercent, fixed_cart, fixed_product (not on PrestaShop: 400), free_shipping
rules.scope{ "kind": "all" }, or products (WooCommerce, Shopify, PrestaShop) / categories (WooCommerce, Magento, PrestaShop) / collections (Shopify) with ids - the store's own ids; products on Magento answers 400
rules.minimumQuantityShopify only
rules.excludeSaleItemsWooCommerce only
rules.usageLimitUses per code, default 1; null = unlimited
rules.expiry{ "mode": "none" }, { "mode": "fixed", "date": "2026-12-31T23:59:59Z" }, or { "mode": "relative", "amount": 14, "unit": "days" | "hours", "endOfDay": true }
codeFormat.charsetunambiguous (letters and digits without look-alikes, default), letters, digits; length 6-20
fallback.policyskip (default), shared_code (needs sharedCode), send_without
lockToPersonWooCommerce, Magento and PrestaShop (the person's customer account)
livetrue once the program has issued a real (non-test) code
rulesVersionGoes up when a live program's rules change; codes already issued keep the rules they were created with

Programs​

List programs​

GET /coupons/programs?status=active&includeArchived=false

Returns an array of program objects. Archived programs are left out unless includeArchived=true.

Create a program​

POST /coupons/programs

The body is the program object without the read-only fields (id, platform, storeName, rulesVersion, stock, live, sampleCode, storeError, stats, dates). slug is [a-z0-9_], unique per workspace. status may be draft (default) or active.

{
"name": "Welcome 10%",
"slug": "welcome10",
"integrationId": "0b8d6f2a-5f43-4e0a-b9a3-7a8b2d1c4e55",
"rules": { "discountType": "percent", "amount": 10, "scope": { "kind": "all" }, "usageLimit": 1, "oncePerCustomer": true, "combinable": false, "expiry": { "mode": "relative", "amount": 14, "unit": "days" } },
"codeFormat": { "length": 10, "charset": "unambiguous", "prefix": "WELCOME-" },
"fallback": { "policy": "skip" },
"reuseOpenCode": true,
"lockToPerson": false
}

Read a program​

GET /coupons/programs/{id}

Update a program​

PATCH /coupons/programs/{id}

Any field of the create body, or status (draft, active, paused, archived). Changing a live program needs settings:write as well as campaigns:write.

Delete a program​

DELETE /coupons/programs/{id}

204 for a program that never issued a real code. A live program answers 409 - archive it instead (PATCH with "status": "archived").

Program report​

GET /coupons/programs/{id}/report
{
"programId": "6c0f3c1e-8a52-4a8e-9a39-2a1d7f0b9a11",
"totals": { "issued": 1240, "redeemed": 311, "redemptionRate": 0.2508, "revenue": 18420.5, "discount": 1842.05, "currency": "EUR", "expired": 402, "failed": 3, "redeemedBySomeoneElse": 12 },
"bySource": [
{ "kind": "journey", "id": "a1b2c3d4-0000-4000-8000-000000000001", "name": "Welcome journey", "issued": 980, "redeemed": 270, "revenue": 15900 }
],
"daily": [{ "date": "2026-10-04", "issued": 52, "redeemed": 14 }]
}

Revenue and discount are both in the workspace reporting currency (currency): an order placed in another currency is converted at that order's own rate, the one its revenue was converted at. Test codes are not counted.

Issue a code​

POST /coupons/programs/{id}/issue

Hands a code from an active program to one contact - for partners who show the code in their own channel. Identify the contact with one of:

FieldDescription
contactIdJoryio's internal contact id
externalIdYour user id
emailThe contact's email
issueKeyOptional idempotency key: the same issueKey always returns the same code
{ "externalId": "customer-123", "issueKey": "order-confirmation-9876" }

Response:

{
"issued": true,
"reused": false,
"applyUrl": "https://shop.example.com/?jry_coupon=WELCOME-7KQ4M2XH9P",
"issue": {
"id": "1f6a...", "programId": "6c0f...", "programSlug": "welcome10", "programName": "Welcome 10%",
"contactId": "66a1f2c3d4e5f6a7b8c9d0e1", "code": "WELCOME-7KQ4M2XH9P", "status": "issued", "isTest": false,
"sourceKind": "api", "campaignId": null, "canvasId": null, "nodeId": null, "channel": null,
"issuedAt": "2026-10-04T09:00:00.000Z", "expiresAt": "2026-10-18T23:59:59.000Z",
"redeemedAt": null, "redeemedOrderId": null, "redeemedRevenue": null, "failureReason": null
}
}

With the program's reuseOpenCode on, a contact who still has an open code from the program gets that code ("reused": true). When no code can be issued the answer is { "issued": false, "reason": "..." } - for example program_paused, store_error, store_permission_missing (the Shopify store has not approved write_discounts yet), store_reconnect_required (WooCommerce was added to the WordPress site after it connected - disconnect and connect again in the Joryio plugin).

Issued codes​

GET /coupons/issues?contactId=66a1f2c3d4e5f6a7b8c9d0e1&programId=&status=&limit=50&offset=0

Returns { "items": [issue, ...], "total": 3 }, newest first. status is pending, issued, failed, redeemed, expired or revoked. limit up to 200.

Connected stores​

GET /coupons/stores
[
{
"integrationId": "0b8d6f2a-5f43-4e0a-b9a3-7a8b2d1c4e55",
"platform": "shopify",
"storeName": "My Shop",
"storeUrl": "https://my-shop.myshopify.com",
"status": "connected",
"capabilities": { "lockToPerson": false, "perCodeExpiry": false, "percentCap": false, "scopes": ["all", "products", "collections"] },
"missingPermission": "write_discounts"
}
]

missingPermission names what the merchant must grant before codes can be created (Shopify: open Integrations → Shopify → Approve new permissions).

Code sources​

A program's source says where its codes come from. It is set on create (default personal) and cannot change once the program has codes. See Code sources.

sourceCodessourceConfig
personalOne code per person, created when a message needs it{}
bankCreated in the store ahead of time, handed out one per person. Needs integrationId.{ "bankTargetSize": 5000 } - keep this many unused codes ready (0 = only on demand and before campaigns)
uploadedUploaded by you (Upload codes), handed out one per person; never created in a store. integrationId optional; lockToPerson not allowed.{}
sharedOne code for everyone, recorded per issue{ "sharedCode": "SUMMER10", "createInStore": true } - createInStore needs integrationId; false only registers a code that already exists

Programs also carry:

FieldValues
lowStock{ "threshold": 500, "percent": null } - for bank and uploaded: alert when fewer than threshold unused codes, or fewer than percent (1-99) % of all codes loaded, remain. null turns a rule off.
stockRead-only. { "available": 1830, "total": 5000, "level": "ok" } for bank and uploaded (level: ok, low, empty); null for the other sources.

For an uploaded program, and a shared program with createInStore: false, the rules only describe the value {{ coupons.<slug>.value }} shows - the store never sees them.

{
"name": "Partner coffee voucher",
"slug": "coffee",
"source": "uploaded",
"integrationId": null,
"lowStock": { "threshold": 200 },
"rules": { "discountType": "fixed_cart", "amount": 3, "currency": "EUR", "scope": { "kind": "all" }, "usageLimit": 1, "oncePerCustomer": true, "combinable": false, "expiry": { "mode": "none" } },
"fallback": { "policy": "send_without" }
}

Stock​

GET /coupons/programs/{id}/stock
{
"programId": "6c0f3c1e-8a52-4a8e-9a39-2a1d7f0b9a11",
"source": "bank",
"tracked": true,
"available": 1830,
"total": 5000,
"used": 3170,
"level": "ok",
"lowStock": { "threshold": 500, "percent": null },
"bank": { "targetSize": 5000, "filling": false, "lastRunAt": "2026-10-04T09:15:00.000Z", "lastAdded": 250, "lastError": null }
}

tracked is false (and the counts 0) for personal and shared programs. bank is present for code banks only; filling is true while a top-up runs.

Upload codes​

POST /coupons/programs/{id}/codes
POST /coupons/programs/{id}/codes?dryRun=true

Adds codes to an uploaded program. Send either a CSV file as multipart/form-data (field file) or JSON { "text": "...", "fileName": "optional.csv" } with the codes pasted. Each upload appends; earlier codes stay. dryRun=true validates and returns the same report without storing anything - the preview.

{ "text": "code,expires_at,value\nCOFFEE-001,2026-12-31,Free coffee\nCOFFEE-002,,Free coffee" }
ColumnRequiredFormat
codeYes3-64 characters: letters, digits and - _ . # / +; compared without regard to case
expires_atNoYYYY-MM-DD (the end of that day, UTC) or an ISO date-time; must be in the future
valueNoUp to 100 characters; shown as {{ coupons.<slug>.value }} for this code

The header row is optional (without it the columns are read in this order); comma, semicolon and tab separators are detected from the first line. Limits: 100,000 rows and 10 MB per upload, 2,000,000 codes per program.

Response (CouponUploadReport):

{
"uploadId": "9d2e4b10-1c3f-4b7a-8a55-0e6f2c9d1a77",
"dryRun": false,
"totalRows": 3,
"accepted": 2,
"rejected": 1,
"duplicates": 1,
"truncated": false,
"maxRows": 100000,
"hasHeader": true,
"preview": [{ "line": 2, "code": "COFFEE-001", "expiresAt": "2026-12-31T23:59:59.000Z", "value": "Free coffee" }],
"rejectedRows": [{ "line": 4, "code": "COFFEE-001", "reason": "duplicate_in_file", "message": "The same code appears earlier in this file." }]
}

uploadId is null on a dry run. preview holds the first 10 accepted rows, rejectedRows the first 500 rejected ones. truncated is true when the file had more rows than maxRows (the rest were not read).

reasonMeaning
missing_codeNo code on the row
invalid_codeNot 3-64 letters, digits and - _ . # / +
invalid_expiryThe expiry is not a date
expiredThe expiry has passed
value_too_longThe value is longer than 100 characters
duplicate_in_fileThe same code appears earlier in the file
already_in_programThe program already has the code
already_existsAnother program of the workspace has the code
over_limitThe program is full

A program with another source answers 400.

Earlier uploads​

GET /coupons/programs/{id}/uploads

The last 100 uploads, newest first: { "id", "kind", "fileName", "totalRows", "accepted", "rejected", "duplicates", "rejectedRows", "createdBy", "createdAt" }.

Generate bank codes​

POST /coupons/programs/{id}/bank/generate
{ "count": 5000 }

Creates count (1-100,000) new codes in the store for a bank program. The work runs in the background: the answer is 202 Accepted with { "started": true, "requested": 5000 } at once; follow it with Stock (bank.filling, bank.lastAdded, bank.lastError). A second request while a top-up runs answers 400. Shopify adds codes to the program's discount, WooCommerce creates one coupon per code, Magento generates them under the program's cart price rule in the program's code format, PrestaShop creates one cart rule per code.

Before a campaign sends, every bank its content uses is topped up to cover the audience automatically. Launch Review blocks a campaign whose audience is larger than the codes a bank or list has (coupon_stock_short) and warns when the send leaves fewer codes than the low-stock alert (coupon_stock_low).

Errors​

StatusWhen
400Validation - for example lockToPerson on a Shopify program (Shopify sets who can use a discount per discount, not per code), a slug that is taken or not [a-z0-9_], shared_code without sharedCode, an expiry date in the past
403A missing permission - including settings:write when changing a live program
404The program is not in this workspace
409Deleting a live program