Skip to main content
Skip to main content
Developers

Store API reference

Connect your own bot, website or shop to your Trigglow store: verify members, read orders and products, sell licence codes elsewhere and get a signed webhook the moment something happens.

Overview

Base URL: https://api.trigglow.com/api/store/v1. Requests and responses are JSON (UTF-8); timestamps are ISO 8601 in UTC; money is an integer in the currency's minor unit (amountCents: 499 with currency: "eur" is 4.99 €). New fields may be added to any response, so ignore the ones you don't know.

curl -H "Authorization: Bearer tgk_…" \
  "https://api.trigglow.com/api/store/v1/orders?limit=20"

Authentication & scopes

Create a key in Store → Members → Advanced (developers). It is shown once; Trigglow only keeps its hash. Send it on every request as Authorization: Bearer tgk_…, from your server — never put a key in a web page or an app people can download. Revoking a key stops it immediately.

Each key only gets the permissions you tick when you create it. A request outside them answers 403 with the scope it needed.

ScopeAllows
members:readLook up a member code, list members.
orders:readList and read orders (sales, renewals, refunds).
products:readList and read your products, drafts included.
licenses:writeIssue licence codes (spends your licence balance), look codes up and void them.

Keys created before scopes existed keep what they could already do: members:read and licenses:write. All scopes: members:read, orders:read, products:read, licenses:write.

Rate limits

Requests are limited per key and per IP address, per minute. Over the limit you get 429 with { "error": "rate_limited" } and a Retry-After: 60 header — wait and try again. Issuing licence codes has its own per-minute limit per store. The current limits are set by Trigglow and can change; build your client to back off on 429 rather than to a fixed number.

Errors

Errors are JSON with a stable machine-readable error code (and sometimes extra fields). Show your own message for the codes you handle; log the rest.

StatuserrorMeaning
400invalid_queryA query parameter is malformed (bad cursor, date, status, id…).
400invalid_bodyThe JSON body is missing or wrong.
400invalid_idempotency_keyIdempotency-Key must be 1–200 printable ASCII characters, no spaces.
401invalid_api_keyNo key, a malformed key, or a revoked one.
402license_balance_requiredNot enough licence balance to issue a code (needCents, balanceCents).
403insufficient_scopeThe key lacks the scope named in required.
403license_codes_disabled / external_sales_disabledLicence codes for products / packs are not available right now.
404not_foundNot found, or it belongs to another store.
409idempotency_conflictThat Idempotency-Key was already used for a different product or pack.
409edition_requiredThe pack has no licensed edition yet (prepare it in Store → Licences).
429rate_limitedSlow down; see Rate limits.

Pagination

Lists are newest first and return { "items": [...], "nextCursor": "…" | null }. Pass limit (default 50, capped by Trigglow) and send nextCursor back as cursor until it is null. Cursors are opaque; don't build them yourself. To sync incrementally, keep the time of your last run and pass it as updatedSince.

let cursor = null;
do {
  const url = new URL("https://api.trigglow.com/api/store/v1/orders");
  url.searchParams.set("limit", "100");
  if (cursor) url.searchParams.set("cursor", cursor);
  const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.TRIGGLOW_KEY}` } });
  const page = await res.json();
  for (const order of page.items) handle(order);
  cursor = page.nextCursor;
} while (cursor);

Members

A membership buyer gets a member code (TG-XXXX-XXXX) on their purchase page. Your bot asks for it and checks it here. If you'd rather not ask for a code, use Sign in with Trigglow, or the membership's Discord role.

GET/members/:codemembers:read

One member, by code (case-insensitive). 404 when it is not a member of your store.

curl -H "Authorization: Bearer tgk_…" https://api.trigglow.com/api/store/v1/members/TG-AB12-CD34

{
  "memberCode": "TG-AB12-CD34",
  "productId": "5b0e…",
  "productName": "VIP Club",
  "buyerEmail": "fan@example.com",
  "status": "active",            // active | past_due | canceled
  "active": true,                // has access right now
  "cancelAtPeriodEnd": false,
  "currentPeriodEnd": "2026-11-19T10:00:00.000Z",
  "interval": "month",           // month | year
  "amountCents": 499,
  "currency": "eur",
  "since": "2026-08-19T10:00:00.000Z",
  "discordUserId": "123456789012345678",
  "updatedAt": "2026-10-19T10:00:00.000Z"
}

Grant access on active, not on status: a canceled membership stays active until its period ends.

GET/membersmembers:read
QueryDescription
productIdOnly members of this product.
statusactive, past_due or canceled.
activetrue / false — has access right now.
emailBuyer email (case-insensitive).
discordUserIdThe member who linked this Discord account — handy for role bots.
updatedSinceOnly members changed at or after this time.
limit, ' ', cursorSee Pagination.

Without limit and cursor this returns up to 1000 members at once (how it worked before pagination); new code should paginate.

Orders

One order per item bought; items paid in one checkout share a cartId. Membership renewals are orders too, with the member's memberCode.

GET/ordersorders:read
QueryDescription
statusComma-separated: paid, refunded, pending, canceled. Default paid,refunded (pending ones are unfinished checkouts).
productIdOnly orders of this product.
emailBuyer email (case-insensitive).
sinceCreated at or after this time.
updatedSinceChanged at or after this time (catches refunds of older orders).
limit, ' ', cursorSee Pagination.
GET/orders/:idorders:read
{
  "id": "9f1c…",
  "cartId": "41aa…",
  "productId": "5b0e…",
  "productName": "Overlay pack",
  "status": "paid",              // pending | paid | refunded | canceled
  "amountCents": 900,            // what the buyer paid for this item
  "discountCents": 100,
  "couponCode": "LAUNCH10",
  "currency": "eur",
  "buyerEmail": "fan@example.com",
  "memberCode": null,            // set on membership payments
  "paidAt": "2026-10-19T10:00:00.000Z",
  "createdAt": "2026-10-19T09:58:00.000Z",
  "updatedAt": "2026-10-19T10:00:00.000Z"
}

Products

GET/productsproducts:read

Your products, drafts and unlisted ones included (deleted ones never). Filters: published=true|false, kind (file, link, service, license, membership, overlay_pack), limit, cursor.

GET/products/:idproducts:read
{
  "id": "5b0e…",
  "name": "VIP Club",
  "kind": "membership",
  "priceCents": 499,
  "currency": "eur",
  "payWhatYouWant": false,
  "billingInterval": "month",    // memberships only
  "published": true,
  "unlisted": false,
  "reviewStatus": "approved",    // draft | in_review | approved | rejected
  "stockLimit": null,
  "soldCount": 42,
  "imageUrl": "https://…",
  "createdAt": "…",
  "updatedAt": "…"
}

Delivery content (files, links, licence keys) is never returned.

Licence codes

Sell a Trigglow product or overlay pack in another shop: when someone buys there, your shop asks Trigglow for one code (paid from your licence balance) and gives it to the buyer, who redeems it in their Trigglow account. Codes look like TGP-XXXX-XXXX-XXXX-XXXX.

POST/products/:id/licenseslicenses:write

One code for a store product (not memberships). Optional body { "note": "order #1001" }.

curl -X POST https://api.trigglow.com/api/store/v1/products/5b0e…/licenses \
  -H "Authorization: Bearer tgk_…" \
  -H "Idempotency-Key: shop-order-1001" \
  -H "Content-Type: application/json" \
  -d '{"note":"order #1001"}'

HTTP 201
{ "code": "TGP-7KQ2-…", "feeCents": 50, "balanceCents": 950, "replayed": false }
POST/overlay-packs/:packId/licenseslicenses:write

Same, for an overlay pack with a licensed edition. The answer also has packVersion.

Retries and Idempotency-Key

Always send an Idempotency-Key — your shop's order id is ideal. If the request times out and you send it again with the same key, you get the same code back (200, "replayed": true) and are charged once. Reusing a key for a different product answers 409 idempotency_conflict. Keys never expire.

POST/licenses/lookuplicenses:write

Body { "code": "TGP-…" }. The code goes in the body, not the URL, so it stays out of logs.

{
  "id": "c0de…",
  "packId": null,
  "productId": "5b0e…",
  "orderId": "9f1c…",            // the Trigglow order once redeemed (products)
  "batch": "api",
  "note": "order #1001",
  "idempotencyKey": "shop-order-1001",
  "status": "redeemed",          // unused | redeemed | revoked
  "redeemedAt": "…",
  "revokedAt": null,
  "createdAt": "…"
}
POST/licenses/revokelicenses:write

Body { "code": "TGP-…" } — e.g. when you refund the sale in your shop. An unused code can no longer be redeemed (and its fee may come back to your balance: refundedCents); a redeemed one takes the access back from the buyer. Revoking twice is harmless.

Webhooks

Add an endpoint in Store → Members → Advanced → Webhooks: a public https URL on your server and the events you want (none ticked = all). Trigglow then POSTs a JSON event to it when something happens. Use Send test to get a ping, and the delivery log to see each attempt, its HTTP status and payload, and to resend.

EventWhendata
order.paidA sale or a membership payment (first charge and every renewal) went through, or a licence code for a product was redeemed.Order
order.refundedAn order was refunded (by you, support or Stripe) or its licence code was voided.Order
membership.createdSomeone bought a membership.Member
membership.updatedStatus, period end or cancel-at-period-end changed (renewal, failed payment, cancellation, expiry).Member
license.redeemedA buyer redeemed one of your licence codes.Licence code (as in lookup)
pingOnly from Send test.{ "message" }

Subscribable events: order.paid, order.refunded, membership.created, membership.updated, license.redeemed.

Request

POST /your/endpoint
Content-Type: application/json
User-Agent: Trigglow-Webhooks/1.0
Trigglow-Event: order.paid
Trigglow-Delivery: 3e5d…            // this attempt's delivery id
Trigglow-Signature: t=1760868000,v1=5f2b…

{
  "id": "a8b1…",                     // event id — the same on every retry
  "type": "order.paid",
  "createdAt": "2026-10-19T10:00:00.000Z",
  "data": { …the Order object… }
}

Verify the signature

Each endpoint has its own signing secret (whsec_…, shown once; New secret replaces it). The signature is HMAC-SHA256(secret, t + "." + rawBody) in hex. Compute it over the raw request body, before any JSON parsing, compare in constant time, and reject timestamps more than 5 minutes old.

// Node.js (Express: app.post('/trigglow', express.raw({ type: 'application/json' }), …))
import crypto from 'node:crypto';

export function verifyTrigglow(rawBody, header, secret, toleranceSec = 300) {
  const parts = Object.fromEntries((header ?? '').split(',').map((p) => p.split('=')));
  const t = Number(parts.t);
  if (!t || Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
  const expected = crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex');
  const given = Buffer.from(parts.v1 ?? '');
  return given.length === expected.length && crypto.timingSafeEqual(given, Buffer.from(expected));
}
# Python
import hashlib, hmac, time

def verify_trigglow(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in (header or "").split(",") if "=" in p)
    t = int(parts.get("t", 0))
    if abs(time.time() - t) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{t}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts.get("v1", ""))

Retries

Answer with any 2xx within a few seconds — do the slow work after replying. Anything else (another status, a timeout, a redirect, a TLS error) is retried with growing waits: about 1 min, 4 min, 16 min, 1 h, 4 h and then every 12 h, a limited number of times. Delivery is at-least-once and order is not guaranteed: dedupe on the event id, and when order matters re-read the object through the API. The delivery log keeps recent history for a limited time.

Sign in with Trigglow

An OAuth 2.0 authorization-code flow so your site or bot learns which Trigglow account is visiting and what they hold from your store, without asking for a member code. Register an app (name + exact redirect URIs) in Store → Members → Advanced; you get a client_id and a client_secret (shown once).

1. Send the visitor to:
   https://www.trigglow.com/oauth/authorize?client_id=tgc_…&redirect_uri=https%3A%2F%2Fyoursite.com%2Fcallback&state=RANDOM

2. They approve and come back to redirect_uri?code=tgcode_…&state=RANDOM  (check state; the code lasts 5 minutes, once)

3. From your server:
   POST https://api.trigglow.com/api/oauth/token
   Content-Type: application/x-www-form-urlencoded

   grant_type=authorization_code&code=tgcode_…&client_id=tgc_…&client_secret=tgs_…&redirect_uri=https://yoursite.com/callback

   → { "access_token": "tgo_…", "token_type": "Bearer", "expires_in": 2592000 }

4. GET https://api.trigglow.com/api/oauth/userinfo
   Authorization: Bearer tgo_…

   → {
       "id": "…", "displayName": "Fan",
       "memberships": [{ "productId": "…", "productName": "VIP Club", "active": true, "status": "active",
                         "cancelAtPeriodEnd": false, "currentPeriodEnd": "…", "interval": "month" }],
       "purchases": [{ "productId": "…", "productName": "Overlay pack" }]
     }

Only your store's memberships and paid purchases are visible to your app. Access tokens last 30 days; after that, send the visitor through step 1 again.