Tulus Partner API
Manage your Tulus coupons directly from your e-shop, ERP or custom software: create, update, schedule, pause and archive coupons, synchronise your catalogue, generate single-use codes, report confirmed redemptions, read usage and analytics, and receive webhooks. The partner dashboard uses exactly the same business rules.
Base URL: https://tulus.ee/api/v1. Keys are created in the partner dashboard and start with tls_live_.
Using WooCommerce? You don't need to code anything — install the Tulus.ee WooCommerce plugin (download and step-by-step guide in Estonian).
Getting started
- Register as a partner at /iseteenindus and wait for approval (Tulus grants access to your shop).
- Open Partner dashboard → API & Integrations, create a key, choose the shops and scopes it may use, and copy the key — it is shown once.
- Call
GET /meto check the key, thenGET /shopsto get your shop IDs. - Create a coupon with
POST /coupons, or synchronise your whole list withPOST /coupons/sync. - When an order uses a coupon, report it with
POST /coupons/{id}/redemptions.
curl -s https://tulus.ee/api/v1/me -H "Authorization: Bearer $TULUS_API_KEY"
Authentication & API keys
Every request must send the key in the Authorization header: Authorization: Bearer tls_test_XXXXXXXXXXXX_…. Keys sent in the URL (?api_key=, ?token=) are rejected with 400 CREDENTIALS_IN_URL.
- A key has a name, a set of scopes, a list of shops (or “all my shops”), an optional expiry and a per-minute rate limit.
- Tulus stores only a keyed hash of the secret. Lost keys cannot be recovered — rotate instead.
- Rotation issues a new key with the same settings; the old key keeps working for a grace period (24 h by default) so you can redeploy without downtime.
- Revocation is immediate. Tulus may also suspend a key that looks compromised.
- Create separate keys per integration (e.g. one for the shop, one read-only key for reporting).
Scopes
| Scope | Allows | Write |
|---|---|---|
analytics.read | Read analytics reports | |
coupon_codes.create | Generate or import unique code batches | yes |
coupon_codes.read | Read unique code batches and codes | |
coupon_redemptions.create | Report and reverse confirmed redemptions | yes |
coupon_redemptions.read | Read confirmed redemptions | |
coupon_usage.read | Read coupon usage statistics | |
coupons.activate | Activate / publish coupons | yes |
coupons.archive | Archive coupons | yes |
coupons.create | Create and duplicate coupons, bulk create | yes |
coupons.export | Export coupon data | |
coupons.pause | Pause coupons | yes |
coupons.read | List and read coupons | |
coupons.sync | Synchronise coupons by external reference | yes |
coupons.update | Update coupons (PATCH/PUT) | yes |
shops.read | Read the shops this key may access | |
webhooks.manage | Manage webhook endpoints | yes |
A key can only ever see the shops explicitly assigned to it and currently assigned to your partner account. Resources of other shops return 404 — Tulus never reveals whether they exist.
Requests, responses & errors
Send and receive JSON (Content-Type: application/json). Timestamps are ISO-8601; responses are always UTC (Z). Money amounts are decimal numbers in the given currency (default EUR). Every response carries a request id in meta.request_id and the X-Request-Id header — include it when contacting support. You may send your own X-Request-Id (8–64 chars).
{
"success": true,
"data": { "id": "01JC…", "object": "coupon", "code": "SUMMER20", "status": "active", "…": "…" },
"meta": { "request_id": "req_6b3f…" }
}{
"success": false,
"error": {
"code": "VALIDATION_ERROR",
"message": "The submitted coupon is invalid.",
"fields": { "ends_at": ["The expiration date must be after the start date."] }
},
"meta": { "request_id": "req_6b3f…" }
}| HTTP | error.code | Meaning |
|---|---|---|
| 400 | INVALID_JSON, CREDENTIALS_IN_URL, INVALID_IDEMPOTENCY_KEY | Malformed request |
| 401 | UNAUTHENTICATED, INVALID_CREDENTIALS, CREDENTIAL_INACTIVE | Missing/invalid/revoked/suspended/expired key, or partner not active |
| 403 | INSUFFICIENT_SCOPE | The key lacks the required scope |
| 404 | NOT_FOUND | Unknown resource, or a resource your key may not access |
| 409 | INVALID_TRANSITION, COUPON_ARCHIVED, NO_CODES, IDEMPOTENCY_IN_PROGRESS … | The action conflicts with the current state |
| 412 | VERSION_CONFLICT | If-Match version is stale |
| 415 | UNSUPPORTED_MEDIA_TYPE | Body is not JSON |
| 422 | VALIDATION_ERROR, IDEMPOTENCY_KEY_REUSED, redemption reason codes | Invalid data or rejected by a business rule |
| 429 | RATE_LIMITED | Too many requests — honour Retry-After |
| 5xx | INTERNAL_ERROR | Our fault; safe to retry idempotent requests with backoff |
Error messages never contain SQL, stack traces or internal identifiers.
Pagination & filtering
List endpoints use cursor pagination: pass limit (max 100) and, for the next page, cursor=meta.pagination.next_cursor. Stop when has_more is false. Cursors are stable while new data arrives.
GET /coupons filters: shop_id, status (repeatable: status[]=active&status[]=paused), type (code type), active (true/false), starts_after, ends_before, updated_after (ideal for incremental sync), external_reference, campaign_id, search; sort with sort=created_at|-created_at|updated_at|-updated_at|ends_at|-ends_at. Archived coupons are excluded unless you filter by status.
Idempotency & safe retries
Send an Idempotency-Key header (8–255 printable characters, e.g. a UUID) on every POST/PATCH/PUT/DELETE. Within 24 hours:
- Same key + same request → the original response is replayed (header
Idempotent-Replayed: true); nothing is created twice. - Same key + different body →
422 IDEMPOTENCY_KEY_REUSED. - Same key while the first request is still running →
409 IDEMPOTENCY_IN_PROGRESS. - Responses with status ≥ 500 are not stored, so you can retry them.
Redemptions are additionally idempotent on external_redemption_id, and sync on external_reference. Retry only on network errors, 429 and 5xx — with exponential backoff and jitter. Do not blindly retry 4xx.
Rate limits
Limits apply per key (default 120 requests/minute, shown on the dashboard), per partner (600/minute across all keys) and per endpoint category: writes 60/min, bulk/sync/export 10/min, analytics 30/min. Every response includes X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (Unix time). Exceeding a limit returns 429 with Retry-After seconds.
Coupons
| code_type | Meaning | code field |
|---|---|---|
public_code | One reusable code for everyone | required |
exclusive_code | One reusable code available only via Tulus | required |
unique_codes | Each customer gets a personal single-use code from a pool | must be null — add codes via code batches |
no_code | Automatic discount, nothing to type at checkout | must be null |
discount_type: percentage (0 < value ≤ 100, decimals allowed), fixed_amount (> 0, with currency), free_shipping, other (describe it in the title). Optional restrictions: min_purchase_amount, max_discount_amount (percentage only), applies_to + applicable_products/applicable_categories, excluded_products, customer_eligibility, restrictions_note, usage_limit_total, usage_limit_per_customer, terms, landing_url, visibility (public, unlisted — link only, private — never shown publicly), category_ids (Tulus category slugs).
curl -s -X POST https://tulus.ee/api/v1/coupons \
-H "Authorization: Bearer $TULUS_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"shop_id": "01JC9K3S7Q8X0M5W2ZB6T4N1RD",
"external_reference": "summer2026",
"title": "-20% kõikidele suvetoodetele",
"code_type": "public_code",
"code": "SUMMER20",
"discount_type": "percentage",
"discount_value": 20,
"min_purchase_amount": 30,
"starts_at": "2026-11-01T00:00:00+02:00",
"ends_at": "2026-11-30T23:59:59+02:00",
"usage_limit_total": 500,
"category_ids": ["kodu-aed"],
"status": "active"
}'PATCH changes only the fields you send — omitted fields keep their values. PUT replaces the coupon (omitted optional fields return to defaults). Send If-Match: "<version>" to avoid overwriting a concurrent edit. Lifecycle actions: POST /coupons/{id}/activate (optionally with starts_at/ends_at to schedule), /pause, /archive, /duplicate. Extend validity or change limits with PATCH.
Coupon statuses
The status is computed by Tulus from your intent, the dates and usage. Paused is never confused with expired.
| status | Meaning |
|---|---|
| draft | Saved, not published |
| pending_approval | Published by a partner that requires review; Tulus will approve or reject |
| scheduled | Approved, starts_at in the future |
| active | Live on Tulus |
| paused | Taken offline by you or Tulus; resume with /activate |
| expired | ends_at has passed |
| exhausted | usage_limit_total reached (or no single-use codes left) |
| rejected | Rejected in review — see rejection_reason; fix and /activate to resubmit |
| archived | Permanently retired; cannot be edited (duplicate it instead) |
Single-use codes
For unique_codes coupons, generate codes (processed in the background; poll the batch) or import your own (up to 5,000 per call, processed immediately):
curl -s -X POST https://tulus.ee/api/v1/coupons/{id}/codes/bulk -H "Authorization: Bearer $TULUS_API_KEY" -H "Content-Type: application/json" \
-d '{"count": 10000, "prefix": "TL-", "length": 10}'
# → 202 { "data": { "id": "01JC…", "status": "queued" } }
curl -s https://tulus.ee/api/v1/coupon-code-batches/01JC… -H "Authorization: Bearer $TULUS_API_KEY"Codes are generated with a cryptographic RNG from an unambiguous alphabet and are unique per coupon. Each Tulus customer who reveals the coupon receives one code; a customer never sees another customer's code. Load the codes into your shop with GET /coupons/{id}/codes. A reported redemption marks the code as redeemed; a second redemption of the same code is rejected.
Synchronisation
POST /coupons/sync keeps Tulus in line with your system, keyed by external_reference (unique per shop — not globally). Up to 500 coupons per request.
- Unknown reference → created. Known and unchanged → unchanged. Known and changed → updated.
- If the coupon was edited in the Tulus dashboard since your last sync, it is a conflict: with
conflict_policy=skip(default) Tulus keeps the manual edit and reports it; withoverwriteyour data wins. Tulus never silently overwrites manual changes. mode=fullalso archives API-managed coupons of that shop that are missing from the payload. Coupons created manually in Tulus (no external_reference) are never archived by sync. Usefullonly with your complete list.- Per item,
"status": "active" | "paused" | "draft"expresses the desired publication state. dry_run: truereturns the plan without writing.
{
"shop_id": "01JC9K3S7Q8X0M5W2ZB6T4N1RD",
"mode": "upsert",
"conflict_policy": "skip",
"coupons": [
{ "external_reference": "summer2026", "title": "-20% suvetoodetele", "code_type": "public_code", "code": "SUMMER20",
"discount_type": "percentage", "discount_value": 20, "ends_at": "2026-11-30T21:59:59Z", "status": "active" }
]
}{ "success": true, "data": { "sync_id": "01JC…", "summary": { "received": 1, "created": 0, "updated": 1, "unchanged": 0, "archived": 0, "conflicts": 0, "errors": 0 },
"results": [ { "index": 0, "external_reference": "summer2026", "action": "updated", "id": "01JC…" } ] }, "meta": { "request_id": "req_…" } }Bulk creation without sync semantics: POST /coupons/bulk — up to 50 items processed inline (per-item results), up to 5,000 as a background operation (202 + operation_id; poll GET /operations/{id}). Exports: GET /coupons/export?format=csv|json.
Redemption reporting
Tulus measures views, code reveals, copies and outbound clicks — but a copied code or a click is not a purchase. A redemption counts only when your shop confirms it:
curl -s -X POST https://tulus.ee/api/v1/coupons/{id}/redemptions \
-H "Authorization: Bearer $TULUS_API_KEY" -H "Content-Type: application/json" \
-d '{
"external_redemption_id": "ORDER-2026-00125",
"external_order_id": "ORDER-2026-00125",
"external_customer_reference": "customer-123",
"coupon_code": "SUMMER20",
"redeemed_at": "2026-11-08T10:30:00Z",
"discount_amount": 20.00,
"order_amount": 100.00,
"currency": "EUR",
"metadata": { "source": "shop_checkout" }
}'| Field | Rules |
|---|---|
| external_redemption_id | Required, ≤190 printable chars. Idempotency key per coupon: repeating it returns the original redemption (200, meta.duplicate=true) and never counts twice — even for simultaneous requests. |
| external_order_id | Optional order reference |
| external_customer_reference | Optional; stored only as a keyed hash; enables usage_limit_per_customer. Do not send e-mails or names. |
| coupon_code | Required for unique_codes (must be an issued, unredeemed code); otherwise checked against the coupon's code if sent |
| redeemed_at | Optional (default now); within the coupon's validity window; not in the future; not older than 180 days |
| discount_amount, order_amount, currency | Optional decimals ≥ 0; currency defaults to EUR |
| metadata | Optional flat object, ≤ 20 short scalar values |
Usage limits are enforced with row-level locking: concurrent requests can never exceed usage_limit_total. Rejections return 422 with one of: COUPON_NOT_PUBLISHED, COUPON_ARCHIVED, OUTSIDE_VALIDITY, USAGE_LIMIT_REACHED, PER_CUSTOMER_LIMIT_REACHED, CODE_REQUIRED, UNKNOWN_CODE, CODE_ALREADY_REDEEMED, CODE_MISMATCH. Rejected attempts are counted separately in statistics.
Cancelled or returned orders: POST /coupons/{id}/redemptions/{redemption_id or external_redemption_id}/reverse with an optional reason. Redemptions are never deleted; reversals are explicit events (a reversed single-use code is voided, not re-issued).
Usage & analytics
GET /coupons/{id}/usage returns limits, confirmed redemptions, remaining uses and lifetime + date-range metrics. Reports (/analytics/overview, /analytics/coupons, /analytics/shops, /analytics/redemptions, /coupons/{id}/analytics) accept from, to (YYYY-MM-DD, max 2 years), shop_id, coupon_id, campaign_id and group=day|week|month.
| Metric | Definition | Source |
|---|---|---|
| views / unique_views | Coupon page views (bots excluded) / distinct daily visitors | measured by Tulus |
| code_reveals | A visitor opened the code | measured |
| code_copies | Copy button or manual copy of a revealed code (debounced 30 s) | measured |
| outbound_clicks / unique_clicks | Clicks to your shop from the coupon | measured |
| redemptions_confirmed / _rejected / _reversed | Reported by your integration | confirmed by you |
| net_redemptions | confirmed − reversed | confirmed |
| discount_total, order_total, average_order_amount | Sums of reported amounts, net of reversals; average over redemptions that included an order amount | confirmed |
| ctr | outbound_clicks / views | measured |
| redemption_rate | net_redemptions / outbound_clicks — meaningful only if you report every redemption | mixed, labelled |
Reports are served from daily aggregates refreshed every 5 minutes. Daily buckets are UTC calendar days ("timezone": "UTC" in responses); timestamps in event-level data are UTC — convert to your timezone for display.
Webhooks
Register an HTTPS endpoint (dashboard or POST /webhooks). The signing secret is returned once. Tulus sends a webhook.ping first; the endpoint becomes active after it answers 2xx.
Events: coupon.created, coupon.updated, coupon.activated, coupon.paused, coupon.expired, coupon.archived, coupon.usage_limit_reached, coupon.redemption.created, coupon.redemption.reversed, campaign.updated.
POST /tulus/webhook HTTP/1.1
Content-Type: application/json
User-Agent: Tulus-Webhooks/1.0
X-Tulus-Event: coupon.redemption.created
X-Tulus-Delivery: evt_01jc9m…
X-Tulus-Timestamp: 1762600000
X-Tulus-Signature: t=1762600000,v1=5f2b…
{"id":"evt_01jc9m…","type":"coupon.redemption.created","api_version":"v1","created_at":"2026-11-08T10:30:01Z",
"data":{"object":{"id":"01JC…","object":"coupon","status":"active","usage":{"confirmed_redemptions":126,"remaining_uses":374}},
"redemption":{"id":"01JC…","external_redemption_id":"ORDER-2026-00125","status":"confirmed"}}}- Answer with any 2xx within 10 seconds; process heavy work asynchronously. Redirects are not followed.
- Failed deliveries are retried after 1 min, 5 min, 30 min, 2 h, 6 h, 12 h and 24 h, then marked abandoned. You can retry manually from the delivery log. After 20 consecutive failures the endpoint is disabled.
- Deliveries can arrive more than once or out of order: de-duplicate on
X-Tulus-Delivery(the event id, unchanged across retries) and re-read the resource if ordering matters. - Only public HTTPS destinations on port 443/8443 are allowed; private, loopback and link-local addresses are refused (SSRF protection), and DNS is re-checked on every delivery.
Verifying signatures
X-Tulus-Signature = t=<unix timestamp>,v1=<hex HMAC-SHA256> computed over "{t}.{raw body}" with your signing secret. During a secret rotation two v1= values are sent (new and previous secret) — accept if any matches. Reject requests older than 5 minutes to prevent replays, and compare in constant time.
<?php
$secret = getenv("TULUS_WEBHOOK_SECRET"); // whsec_…
$body = file_get_contents("php://input"); // raw body, before json_decode
$header = $_SERVER["HTTP_X_TULUS_SIGNATURE"] ?? "";
preg_match("/t=(\d+)/", $header, $t);
preg_match_all("/v1=([a-f0-9]{64})/", $header, $sigs);
$timestamp = (int) ($t[1] ?? 0);
$expected = hash_hmac("sha256", $timestamp . "." . $body, $secret);
$valid = abs(time() - $timestamp) <= 300
&& array_filter($sigs[1], fn ($s) => hash_equals($expected, $s));
if (!$valid) { http_response_code(400); exit; }
$event = json_decode($body, true);
// de-duplicate on $event["id"], then queue your processing
http_response_code(204);import crypto from "node:crypto";
import express from "express";
const app = express();
app.post("/tulus/webhook", express.raw({ type: "application/json" }), (req, res) => {
const header = req.get("X-Tulus-Signature") || "";
const t = Number((header.match(/t=(\d+)/) || [])[1]);
const sigs = [...header.matchAll(/v1=([a-f0-9]{64})/g)].map((m) => m[1]);
const expected = crypto.createHmac("sha256", process.env.TULUS_WEBHOOK_SECRET)
.update(`${t}.${req.body}`).digest("hex");
const ok = Math.abs(Date.now() / 1000 - t) <= 300 &&
sigs.some((s) => crypto.timingSafeEqual(Buffer.from(s), Buffer.from(expected)));
if (!ok) return res.sendStatus(400);
const event = JSON.parse(req.body);
// de-duplicate on event.id, then process asynchronously
res.sendStatus(204);
});
Endpoint reference
The complete, machine-readable contract (all fields and types) is the OpenAPI specification; import the Postman collection to try it.
| Method | Path | Summary |
|---|---|---|
GET | /me | Describe the current API key |
GET | /shops | List shops this key may access |
GET | /categories | List public coupon categories (ids for category_ids) |
GET | /shops/{id} | Get a shop |
GET | /coupons | List coupons |
POST | /coupons | Create a coupon |
GET | /coupons/{id} | Get a coupon |
PATCH | /coupons/{id} | Partially update a coupon |
PUT | /coupons/{id} | Replace a coupon (omitted optional fields reset to defaults) |
POST | /coupons/{id}/activate | Publish / resume (optionally schedule with starts_at/ends_at) |
POST | /coupons/{id}/pause | Pause a coupon |
POST | /coupons/{id}/archive | Archive a coupon (irreversible; duplicate to reuse) |
POST | /coupons/{id}/duplicate | Duplicate as a new draft |
POST | /coupons/bulk | Bulk create (≤50 inline, ≤5000 async) |
POST | /coupons/sync | Synchronise coupons by external_reference |
GET | /coupons/export | Export coupons as CSV/JSON (≤1000 inline, larger async) |
GET | /coupons/{id}/usage | Usage: limits, confirmed redemptions, measured engagement |
GET | /coupons/{id}/analytics | Time series for one coupon |
GET | /coupons/{id}/redemptions | List confirmed/reversed redemptions |
POST | /coupons/{id}/redemptions | Report a confirmed redemption (idempotent on external_redemption_id) |
POST | /coupons/{id}/redemptions/{redemption}/reverse | Reverse (cancel) a redemption |
GET | /coupons/{id}/codes | List single-use codes |
POST | /coupons/{id}/codes/bulk | Generate (async) or import single-use codes |
GET | /coupon-code-batches/{id} | Code batch status |
GET | /operations/{id} | Async operation status |
GET | /operations/{id}/download | Download an export result |
GET | /analytics/overview | Totals for a period (optionally with series via group) |
GET | /analytics/coupons | Per-coupon breakdown |
GET | /analytics/shops | Per-shop breakdown |
GET | /analytics/redemptions | Redemption log across shops, or series with group= |
GET | /webhooks | List webhook endpoints |
POST | /webhooks | Create a webhook endpoint (signing secret returned once) |
GET | /webhooks/{id} | Get an endpoint |
PATCH | /webhooks/{id} | Update URL, events or enabled |
DELETE | /webhooks/{id} | Delete an endpoint |
POST | /webhooks/{id}/rotate-secret | Rotate the signing secret (old one valid 24 h) |
POST | /webhooks/{id}/ping | Send a test webhook.ping |
GET | /webhooks/{id}/deliveries | Delivery log |
POST | /webhook-deliveries/{id}/retry | Retry a failed delivery (same event id) |
Versioning
The version is part of the URL (/api/v1). Within v1 we only make additive changes: new endpoints, new optional request fields, new response fields and new enum values (handle unknown values gracefully) and new webhook event types (you only receive events you subscribed to). Breaking changes ship as /api/v2 with at least 6 months of overlap and advance notice to all key owners. Webhook payloads carry api_version.
Integration examples
PHP — create or update coupons and report a redemption
<?php
final class TulusClient
{
public function __construct(private string $key, private string $base = "https://tulus.ee/api/v1") {}
public function request(string $method, string $path, ?array $body = null, ?string $idempotencyKey = null): array
{
$headers = ["Authorization: Bearer {$this->key}", "Accept: application/json"];
if ($body !== null) { $headers[] = "Content-Type: application/json"; }
if ($idempotencyKey) { $headers[] = "Idempotency-Key: $idempotencyKey"; }
for ($attempt = 1; ; $attempt++) {
$ch = curl_init($this->base . $path);
curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $headers, CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 20, CURLOPT_POSTFIELDS => $body !== null ? json_encode($body) : null]);
$raw = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
$retryable = $raw === false || $status === 429 || $status >= 500;
if (!$retryable || $attempt >= 5) {
return ["status" => $status, "body" => json_decode((string) $raw, true)];
}
usleep((int) (min(30, 2 ** $attempt) * 1e6 + random_int(0, 500000))); // backoff + jitter
}
}
}
$tulus = new TulusClient(getenv("TULUS_API_KEY"));
$shopId = $tulus->request("GET", "/shops")["body"]["data"][0]["id"];
// Keep Tulus in sync with the coupons in your shop
$result = $tulus->request("POST", "/coupons/sync", [
"shop_id" => $shopId,
"coupons" => [[
"external_reference" => "woo-123", "title" => "-15% uutele klientidele", "code_type" => "public_code",
"code" => "TERE15", "discount_type" => "percentage", "discount_value" => 15,
"customer_eligibility" => "new_customers", "status" => "active",
]],
], idempotencyKey: bin2hex(random_bytes(16)));
// After checkout
$tulus->request("POST", "/coupons/{$couponId}/redemptions", [
"external_redemption_id" => $order->id(),
"coupon_code" => $order->couponCode(),
"order_amount" => $order->total(),
"discount_amount" => $order->discount(),
], idempotencyKey: "redeem-" . $order->id());JavaScript (Node 18+)
const BASE = "https://tulus.ee/api/v1";
async function tulus(method, path, body, idempotencyKey) {
for (let attempt = 1; ; attempt++) {
const res = await fetch(BASE + path, {
method,
headers: {
Authorization: `Bearer ${process.env.TULUS_API_KEY}`,
Accept: "application/json",
...(body ? { "Content-Type": "application/json" } : {}),
...(idempotencyKey ? { "Idempotency-Key": idempotencyKey } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
if ((res.status === 429 || res.status >= 500) && attempt < 5) {
const wait = Number(res.headers.get("Retry-After")) || 2 ** attempt;
await new Promise((r) => setTimeout(r, wait * 1000 + Math.random() * 500));
continue;
}
return { status: res.status, body: await res.json() };
}
}
// Incremental pull of everything changed since the last run
let cursor = null;
do {
const qs = new URLSearchParams({ updated_after: lastRun, limit: "100", ...(cursor ? { cursor } : {}) });
const { body } = await tulus("GET", `/coupons?${qs}`);
for (const coupon of body.data) upsertLocally(coupon);
cursor = body.meta.pagination.next_cursor;
} while (cursor);
await tulus("POST", `/coupons/${couponId}/pause`, {}, crypto.randomUUID());Recommended integration pattern
- Store the Tulus coupon
idnext to your own coupon, and always send your id asexternal_reference. - Push changes with
/coupons/sync(modeupsert) when coupons change in your shop; run a nightlyfullsync if your list is authoritative. - Report every order that used a Tulus coupon via
/redemptions; reverse on refund. - Subscribe to
coupon.paused,coupon.expiredandcoupon.usage_limit_reachedto keep your checkout in step.