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

  1. Register as a partner at /iseteenindus and wait for approval (Tulus grants access to your shop).
  2. Open Partner dashboard → API & Integrations, create a key, choose the shops and scopes it may use, and copy the key — it is shown once.
  3. Call GET /me to check the key, then GET /shops to get your shop IDs.
  4. Create a coupon with POST /coupons, or synchronise your whole list with POST /coupons/sync.
  5. 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

ScopeAllowsWrite
analytics.readRead analytics reports
coupon_codes.createGenerate or import unique code batchesyes
coupon_codes.readRead unique code batches and codes
coupon_redemptions.createReport and reverse confirmed redemptionsyes
coupon_redemptions.readRead confirmed redemptions
coupon_usage.readRead coupon usage statistics
coupons.activateActivate / publish couponsyes
coupons.archiveArchive couponsyes
coupons.createCreate and duplicate coupons, bulk createyes
coupons.exportExport coupon data
coupons.pausePause couponsyes
coupons.readList and read coupons
coupons.syncSynchronise coupons by external referenceyes
coupons.updateUpdate coupons (PATCH/PUT)yes
shops.readRead the shops this key may access
webhooks.manageManage webhook endpointsyes

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…" }
}
HTTPerror.codeMeaning
400INVALID_JSON, CREDENTIALS_IN_URL, INVALID_IDEMPOTENCY_KEYMalformed request
401UNAUTHENTICATED, INVALID_CREDENTIALS, CREDENTIAL_INACTIVEMissing/invalid/revoked/suspended/expired key, or partner not active
403INSUFFICIENT_SCOPEThe key lacks the required scope
404NOT_FOUNDUnknown resource, or a resource your key may not access
409INVALID_TRANSITION, COUPON_ARCHIVED, NO_CODES, IDEMPOTENCY_IN_PROGRESS …The action conflicts with the current state
412VERSION_CONFLICTIf-Match version is stale
415UNSUPPORTED_MEDIA_TYPEBody is not JSON
422VALIDATION_ERROR, IDEMPOTENCY_KEY_REUSED, redemption reason codesInvalid data or rejected by a business rule
429RATE_LIMITEDToo many requests — honour Retry-After
5xxINTERNAL_ERROROur 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_typeMeaningcode field
public_codeOne reusable code for everyonerequired
exclusive_codeOne reusable code available only via Tulusrequired
unique_codesEach customer gets a personal single-use code from a poolmust be null — add codes via code batches
no_codeAutomatic discount, nothing to type at checkoutmust 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.

statusMeaning
draftSaved, not published
pending_approvalPublished by a partner that requires review; Tulus will approve or reject
scheduledApproved, starts_at in the future
activeLive on Tulus
pausedTaken offline by you or Tulus; resume with /activate
expiredends_at has passed
exhaustedusage_limit_total reached (or no single-use codes left)
rejectedRejected in review — see rejection_reason; fix and /activate to resubmit
archivedPermanently 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; with overwrite your data wins. Tulus never silently overwrites manual changes.
  • mode=full also 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. Use full only with your complete list.
  • Per item, "status": "active" | "paused" | "draft" expresses the desired publication state.
  • dry_run: true returns 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" }
  }'
FieldRules
external_redemption_idRequired, ≤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_idOptional order reference
external_customer_referenceOptional; stored only as a keyed hash; enables usage_limit_per_customer. Do not send e-mails or names.
coupon_codeRequired for unique_codes (must be an issued, unredeemed code); otherwise checked against the coupon's code if sent
redeemed_atOptional (default now); within the coupon's validity window; not in the future; not older than 180 days
discount_amount, order_amount, currencyOptional decimals ≥ 0; currency defaults to EUR
metadataOptional 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.

MetricDefinitionSource
views / unique_viewsCoupon page views (bots excluded) / distinct daily visitorsmeasured by Tulus
code_revealsA visitor opened the codemeasured
code_copiesCopy button or manual copy of a revealed code (debounced 30 s)measured
outbound_clicks / unique_clicksClicks to your shop from the couponmeasured
redemptions_confirmed / _rejected / _reversedReported by your integrationconfirmed by you
net_redemptionsconfirmed − reversedconfirmed
discount_total, order_total, average_order_amountSums of reported amounts, net of reversals; average over redemptions that included an order amountconfirmed
ctroutbound_clicks / viewsmeasured
redemption_ratenet_redemptions / outbound_clicks — meaningful only if you report every redemptionmixed, 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.

MethodPathSummary
GET/meDescribe the current API key
GET/shopsList shops this key may access
GET/categoriesList public coupon categories (ids for category_ids)
GET/shops/{id}Get a shop
GET/couponsList coupons
POST/couponsCreate 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}/activatePublish / resume (optionally schedule with starts_at/ends_at)
POST/coupons/{id}/pausePause a coupon
POST/coupons/{id}/archiveArchive a coupon (irreversible; duplicate to reuse)
POST/coupons/{id}/duplicateDuplicate as a new draft
POST/coupons/bulkBulk create (≤50 inline, ≤5000 async)
POST/coupons/syncSynchronise coupons by external_reference
GET/coupons/exportExport coupons as CSV/JSON (≤1000 inline, larger async)
GET/coupons/{id}/usageUsage: limits, confirmed redemptions, measured engagement
GET/coupons/{id}/analyticsTime series for one coupon
GET/coupons/{id}/redemptionsList confirmed/reversed redemptions
POST/coupons/{id}/redemptionsReport a confirmed redemption (idempotent on external_redemption_id)
POST/coupons/{id}/redemptions/{redemption}/reverseReverse (cancel) a redemption
GET/coupons/{id}/codesList single-use codes
POST/coupons/{id}/codes/bulkGenerate (async) or import single-use codes
GET/coupon-code-batches/{id}Code batch status
GET/operations/{id}Async operation status
GET/operations/{id}/downloadDownload an export result
GET/analytics/overviewTotals for a period (optionally with series via group)
GET/analytics/couponsPer-coupon breakdown
GET/analytics/shopsPer-shop breakdown
GET/analytics/redemptionsRedemption log across shops, or series with group=
GET/webhooksList webhook endpoints
POST/webhooksCreate 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-secretRotate the signing secret (old one valid 24 h)
POST/webhooks/{id}/pingSend a test webhook.ping
GET/webhooks/{id}/deliveriesDelivery log
POST/webhook-deliveries/{id}/retryRetry 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

  1. Store the Tulus coupon id next to your own coupon, and always send your id as external_reference.
  2. Push changes with /coupons/sync (mode upsert) when coupons change in your shop; run a nightly full sync if your list is authoritative.
  3. Report every order that used a Tulus coupon via /redemptions; reverse on refund.
  4. Subscribe to coupon.paused, coupon.expired and coupon.usage_limit_reached to keep your checkout in step.

Sooduskood