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.
| Scope | Allows |
|---|---|
members:read | Look up a member code, list members. |
orders:read | List and read orders (sales, renewals, refunds). |
products:read | List and read your products, drafts included. |
licenses:write | Issue 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.
| Status | error | Meaning |
|---|---|---|
| 400 | invalid_query | A query parameter is malformed (bad cursor, date, status, id…). |
| 400 | invalid_body | The JSON body is missing or wrong. |
| 400 | invalid_idempotency_key | Idempotency-Key must be 1–200 printable ASCII characters, no spaces. |
| 401 | invalid_api_key | No key, a malformed key, or a revoked one. |
| 402 | license_balance_required | Not enough licence balance to issue a code (needCents, balanceCents). |
| 403 | insufficient_scope | The key lacks the scope named in required. |
| 403 | license_codes_disabled / external_sales_disabled | Licence codes for products / packs are not available right now. |
| 404 | not_found | Not found, or it belongs to another store. |
| 409 | idempotency_conflict | That Idempotency-Key was already used for a different product or pack. |
| 409 | edition_required | The pack has no licensed edition yet (prepare it in Store → Licences). |
| 429 | rate_limited | Slow 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.
/members/:codemembers:readOne 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.
/membersmembers:read| Query | Description |
|---|---|
productId | Only members of this product. |
status | active, past_due or canceled. |
active | true / false — has access right now. |
email | Buyer email (case-insensitive). |
discordUserId | The member who linked this Discord account — handy for role bots. |
updatedSince | Only members changed at or after this time. |
limit, ' ', cursor | See 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.
/ordersorders:read| Query | Description |
|---|---|
status | Comma-separated: paid, refunded, pending, canceled. Default paid,refunded (pending ones are unfinished checkouts). |
productId | Only orders of this product. |
email | Buyer email (case-insensitive). |
since | Created at or after this time. |
updatedSince | Changed at or after this time (catches refunds of older orders). |
limit, ' ', cursor | See Pagination. |
/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
/productsproducts:readYour products, drafts and unlisted ones included (deleted ones never). Filters: published=true|false, kind (file, link, service, license, membership, overlay_pack), limit, cursor.
/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.
/products/:id/licenseslicenses:writeOne 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 }/overlay-packs/:packId/licenseslicenses:writeSame, 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.
/licenses/lookuplicenses:writeBody { "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": "…"
}/licenses/revokelicenses:writeBody { "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.
| Event | When | data |
|---|---|---|
order.paid | A sale or a membership payment (first charge and every renewal) went through, or a licence code for a product was redeemed. | Order |
order.refunded | An order was refunded (by you, support or Stripe) or its licence code was voided. | Order |
membership.created | Someone bought a membership. | Member |
membership.updated | Status, period end or cancel-at-period-end changed (renewal, failed payment, cancellation, expiry). | Member |
license.redeemed | A buyer redeemed one of your licence codes. | Licence code (as in lookup) |
ping | Only 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.