API · Gift vouchers
The voucher API uses the same account-scoped Admin URLs as the dashboard. Reads require Read; issuance, products, delivery retries, adjustments, blocks, and imports require Read + Write. All IDs are opaque strings. Store them as returned and never substitute or infer a database ID.
Voucher codes are bearer secrets. Collection and detail responses expose only a masked suffix. A manually issued code is returned once in the create response; imports only show masked previews.
Voucher products
GET /admin/voucher_products
Returns every active or archived product in catalog order:
{
"voucher_products": [
{
"id": "mN9uR4pKc8xQ",
"name": "Gift card",
"description": "For any Usetix Club event",
"pricing_type": "flexible",
"visibility": "public_catalog",
"status": "active",
"currency": "EUR",
"fixed_amount": null,
"purchase_price": null,
"bonus_amount": null,
"minimum_amount": { "amount": "10.00", "currency": "EUR" },
"maximum_amount": { "amount": "250.00", "currency": "EUR" },
"validity_months": 36,
"position": 0,
"image_url": "https://app.usetix.io/rails/active_storage/blobs/redirect/...",
"created_at": "2026-08-30T12:00:00Z",
"updated_at": "2026-08-30T12:00:00Z"
}
]
}
GET /admin/voucher_products/:id returns the same object without an envelope.
POST /admin/voucher_products
Create a fixed product:
curl -X POST \
-H "Authorization: Bearer $USETIX_TOKEN" \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-d '{"voucher_product":{"name":"Gift 50","pricing_type":"fixed","fixed_amount":"50.00","visibility":"public_catalog","validity_months":24}}' \
https://app.usetix.io/admin/voucher_products
For pricing_type: "flexible", omit fixed_amount and provide minimum_amount and maximum_amount. visibility is public_catalog or secret. Success returns 201 Created, the object, and Location.
For a bonus product, keep pricing_type: "fixed" and set purchase_price to
a positive value lower than fixed_amount. For example, a fixed_amount of
75.00 with a purchase_price of 50.00 sells €75 of voucher credit for €50;
the response reports bonus_amount as €25.
image accepts an Active Storage signed ID from the standard direct-upload flow. Send it on create or update; the returned image_url is null when no image is attached.
The dashboard manages position with drag-and-drop sorting. API clients can
send PATCH /admin/voucher_products/:voucher_product_id/position with
{ "position": 2 }; success returns 204 No Content.
PATCH /admin/voucher_products/:id
Send only changed fields. When switching pricing type, clear fields from the old type:
{
"voucher_product": {
"pricing_type": "flexible",
"fixed_amount": null,
"minimum_amount": "10.00",
"maximum_amount": "200.00"
}
}
DELETE /admin/voucher_products/:id
Archives the product and returns 204 No Content. Existing vouchers and purchases are retained.
DELETE /admin/voucher_products/:voucher_product_id/image
Removes the current product artwork and returns 204 No Content. The product and all issued vouchers remain unchanged.
Vouchers
GET /admin/vouchers
Returns vouchers newest first with cursor pagination. The default page size is
50; limit accepts 1 to 100. Follow pagination.next_page or the
Link: ...; rel="next" header. Optional filters:
| Parameter | Meaning |
|---|---|
status=active |
Redeemable, positive-balance vouchers. |
status=blocked |
Blocked vouchers. |
status=expired |
Vouchers whose expiration has passed. |
status=depleted |
Zero-balance vouchers. |
{
"summary": {
"count": 12,
"issued": { "amount": "600.00", "currency": "EUR" },
"redeemed": { "amount": "168.50", "currency": "EUR" },
"outstanding": { "amount": "431.50", "currency": "EUR" },
"sold_count": 8,
"sold": { "amount": "400.00", "currency": "EUR" },
"bonus": { "amount": "25.00", "currency": "EUR" },
"blocked_count": 1
},
"vouchers": [
{
"id": "q7R9mT2vX4pL8nK6",
"masked_code": "••••-NPQR",
"product_id": "mN9uR4pKc8xQ",
"product_name": "Gift card",
"source": "shop",
"status": "active",
"issued_amount": { "amount": "50.00", "currency": "EUR" },
"balance": { "amount": "31.50", "currency": "EUR" },
"available_balance": { "amount": "21.50", "currency": "EUR" },
"expires_at": "2029-08-30T12:00:00Z",
"blocked_at": null,
"block_reason": null,
"created_at": "2026-08-30T12:00:00Z",
"updated_at": "2026-08-30T12:05:00Z"
}
]
}
available_balance excludes funds reserved by pending ticket checkouts.
The unfiltered summary is the account-wide voucher report, even when the list is filtered. sold_count and sold include currently paid shop purchases and exclude fully refunded purchases; bonus is shop credit issued above the amount paid. outstanding excludes already expired balances, even before the expiration job has posted its immutable ledger entry. Request /admin/vouchers.csv to stream the complete filtered set as code, product, issued amount, balance, currency, source, expiration, block state, and creation time. JSON uses masked codes while the CSV intentionally contains full bearer codes. Both responses are private and not cacheable.
Exact-code lookup
Use POST /admin/voucher_lookup with a read or write token:
{ "code": "ABCD-2345-EFGH-6789" }
The response is the matching masked voucher object. The code is normalized like a checkout code. This deliberately uses a POST body—even though lookup is read-only—so the monetary bearer secret never enters URLs, browser history, access logs, or proxy query logs. Do not put voucher codes in a GET query string.
GET /admin/vouchers/:id
Returns the voucher object plus its immutable entries newest first. For a voucher bought in the shop, purchase contains the account-scoped support data; it is null for manually issued and imported vouchers:
{
"purchase": {
"id": "r4Nd0mPurchaseToken",
"status": "paid",
"payment_provider": "stripe",
"amount": { "amount": "50.00", "currency": "EUR" },
"paid_amount": { "amount": "40.00", "currency": "EUR" },
"bonus_amount": { "amount": "10.00", "currency": "EUR" },
"customer_name": "Buyer Name",
"customer_email": "buyer@example.com",
"recipient_name": "Recipient Name",
"recipient_email": "recipient@example.com",
"delivery_mode": "email_scheduled",
"scheduled_for": "2026-09-15T08:00:00Z",
"message": "Enjoy the show!",
"paid_at": "2026-08-30T12:00:00Z",
"delivered_at": null,
"created_at": "2026-08-30T11:58:00Z",
"deliveries": [
{
"id": "d3L1vEryToken",
"audience": "recipient",
"recipient_name": "Recipient Name",
"recipient_email": "recipient@example.com",
"scheduled_for": "2026-09-15T08:00:00Z",
"queued_at": "2026-08-30T12:00:00Z",
"delivering_at": null,
"last_attempted_at": null,
"delivered_at": null,
"failed_at": null,
"failure_message": null,
"attempts_count": 0
}
]
}
}
delivery_mode is self_managed, email_now, or email_scheduled. For
self_managed, the delivery snapshot targets the buyer even when an optional
recipient name was entered for the printed voucher. Delivery rows are durable
attempt records; use their public id for a retry, never the purchase or voucher
ID. The public shop route, PDF, and Wallet pass contain the bearer voucher code,
but the order API deliberately exposes IDs and delivery metadata instead of the
code on a line item.
POST /admin/voucher_deliveries/:voucher_delivery_id/retry
Queues a failed delivery or one whose worker claim became stale. A queued job
that has never started becomes recoverable five minutes after it was due;
previously attempted or actively delivering jobs keep the one-hour safety
window. This requires a write token and returns 202 Accepted:
{ "id": "d3L1vEryToken", "status": "queued" }
A delivery that is freshly queued, still scheduled for the future, delivered,
or still actively being attempted returns 422 Unprocessable Entity with an
error under errors.base. Retrying reuses the durable delivery record and its
immutable recipient snapshot; it does not create another voucher or redirect
the message to a new address.
entries use this shape:
{
"id": "q7R9mT2vX4pL8nK6",
"masked_code": "••••-NPQR",
"entries": [
{
"id": "opaque-ledger-entry-id",
"kind": "redemption",
"amount": { "amount": "-18.50", "currency": "EUR" },
"balance_after": { "amount": "31.50", "currency": "EUR" },
"order_id": "ord_c3a9f4e1",
"actor": null,
"reason": null,
"metadata": {},
"created_at": "2026-08-30T12:05:00Z"
}
]
}
Entry kinds are issuance, redemption, refund, adjustment_credit, adjustment_debit, expiration, and purchase_refund.
POST /admin/vouchers
{
"voucher": {
"voucher_product_id": "mN9uR4pKc8xQ",
"amount": "50.00",
"expires_at": "2027-12-31T23:59:59Z",
"note": "Partner campaign"
}
}
amount may be omitted for a fixed product. voucher_product_id, expires_at,
and note are optional. Usetix always generates a cryptographically random
code; this endpoint does not accept a custom code. Success returns
201 Created and Location, with the full code in this response only.
Subsequent reads return masked_code. When voucher_product_id identifies a
bonus product, Usetix records its lower purchase_price as paid consideration
for the issued credit.
POST /admin/vouchers/:voucher_id/adjustment
Credits or debits the balance. reason is mandatory and becomes part of the immutable audit trail.
{ "direction": "debit", "amount": "5.00", "reason": "Duplicate goodwill credit" }
The debit cannot exceed the currently available balance. A successful request returns the updated voucher. Human dashboard requests require an account owner; integrations can use a Read + Write token.
Block and unblock
POST /admin/vouchers/:voucher_id/block
DELETE /admin/vouchers/:voucher_id/block
Blocking requires { "reason": "Compromised code" }. Both operations return the updated voucher. A blocked voucher cannot create new checkout reservations.
CSV imports
Imports are preview-first and all-or-nothing.
POST /admin/voucher_imports
Send raw CSV inside the JSON csv field:
{
"csv": "code,amount,expires_at,note,product_id\nPARTNER-2026,25.00,2027-12-31T23:59:59Z,Partner campaign,\n"
}
Success returns 201 Created with:
{
"id": "K8vQ2mR7xN4pT9cL",
"status": "analyzed",
"rows_count": 1,
"valid_rows_count": 1,
"imported_rows_count": 0,
"validation_errors": [],
"failure_message": null,
"analyzed_at": "2026-08-30T12:00:00Z",
"applied_at": null,
"created_at": "2026-08-30T12:00:00Z"
}
Validation errors use { "line", "field", "message" }. Nothing is issued while errors exist. Uploads are limited to 5 MiB and 5,000 data rows. code is optional; blank codes are generated securely. Provided codes must normalize to 12–64 letters or digits and be unique in the account.
Inspect and apply
GET /admin/voucher_imports
GET /admin/voucher_imports/:id
POST /admin/voucher_imports/:voucher_import_id/application
The show response adds normalized preview rows. Applying a clean preview returns
202 Accepted with status applying and queues the work. Poll the show endpoint
until the status becomes applied or failed. The background application still
issues every voucher in one database transaction; any race or validation failure
rolls the complete import back and records failed plus failure_message on the
import. Repeating the apply request while it is queued or after completion returns
422 and never issues rows twice. If a worker dies after claiming an import,
an applying import older than one hour can be submitted again; a more recent
claim remains protected from a concurrent second apply.
Import creation and application require an account owner in the dashboard or a
Read + Write token through the API.
Security and caching
Voucher responses use Cache-Control: private, no-store. Do not log request
bodies containing voucher codes, and never send codes as URL query parameters.
All resources are isolated to the token’s account; foreign opaque IDs return
404 Not Found.
Related automation
The built-in assistant and MCP expose delivery history plus the confirmed
retry_delivery action through the shared manage_vouchers tool. Typed terminal
workflows live under usetix vouchers, including
usetix vouchers retry-delivery DELIVERY_ID --yes; every endpoint remains
reachable with usetix api. See gift vouchers
for checkout, delivery, Wallet, refund, and accounting behavior, and
webhooks for real-time events.