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
| Permission | Allows |
|---|---|
campaigns:read | List and read programs, the report and the connected stores |
campaigns:write | Create programs, change programs that have not issued codes yet, delete them, issue a code |
settings:write | Needed in addition to change a live program - one that has issued real codes |
users:read | List 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"
}
| Field | Values |
|---|---|
integrationId | A connected store, or null: Joryio generates and tracks the codes without a store |
status | draft, active, paused, archived - only active issues codes |
rules.discountType | percent, 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.minimumQuantity | Shopify only |
rules.excludeSaleItems | WooCommerce only |
rules.usageLimit | Uses 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.charset | unambiguous (letters and digits without look-alikes, default), letters, digits; length 6-20 |
fallback.policy | skip (default), shared_code (needs sharedCode), send_without |
lockToPerson | WooCommerce, Magento and PrestaShop (the person's customer account) |
live | true once the program has issued a real (non-test) code |
rulesVersion | Goes 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:
| Field | Description |
|---|---|
contactId | Joryio's internal contact id |
externalId | Your user id |
email | The contact's email |
issueKey | Optional 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.
source | Codes | sourceConfig |
|---|---|---|
personal | One code per person, created when a message needs it | {} |
bank | Created 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) |
uploaded | Uploaded by you (Upload codes), handed out one per person; never created in a store. integrationId optional; lockToPerson not allowed. | {} |
shared | One code for everyone, recorded per issue | { "sharedCode": "SUMMER10", "createInStore": true } - createInStore needs integrationId; false only registers a code that already exists |
Programs also carry:
| Field | Values |
|---|---|
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. |
stock | Read-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" }
| Column | Required | Format |
|---|---|---|
code | Yes | 3-64 characters: letters, digits and - _ . # / +; compared without regard to case |
expires_at | No | YYYY-MM-DD (the end of that day, UTC) or an ISO date-time; must be in the future |
value | No | Up 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).
reason | Meaning |
|---|---|
missing_code | No code on the row |
invalid_code | Not 3-64 letters, digits and - _ . # / + |
invalid_expiry | The expiry is not a date |
expired | The expiry has passed |
value_too_long | The value is longer than 100 characters |
duplicate_in_file | The same code appears earlier in the file |
already_in_program | The program already has the code |
already_exists | Another program of the workspace has the code |
over_limit | The 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
| Status | When |
|---|---|
400 | Validation - 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 |
403 | A missing permission - including settings:write when changing a live program |
404 | The program is not in this workspace |
409 | Deleting a live program |