Developers
Examples
Working example applications and integration patterns.
- Written against
- The v1 specification
- Runnable
- Locally — against the in-repo sandbox server
- Language
- TypeScript, fetch only
- Environment
- Sandbox
Overview
Three integrations, one loop
Almost every integration is the same four moves: describe a customer, give them somewhere to hold value, define what earns it, then send the events your business already produces. The reward engine does the rest.
The three examples below are that loop in three shapes — a retail purchase, a game session, a membership renewal. They differ in the asset and the rule, not in the structure.
All of them run against the sandbox environment. Nothing in an example touches production, and no example issues anything on a public network.
Setup
Every request looks like this
One helper, used by all three examples. The four headers are required on every authenticated call; the fifth is required only on writes that declare idempotency.
const BASE = 'https://sandbox-api.matinee.ai/v1'
type Options = { idempotencyKey?: string; body?: unknown; method?: string }
export async function call<T>(path: string, options: Options = {}): Promise<T> {
const response = await fetch(BASE + path, {
method: options.method ?? (options.body ? 'POST' : 'GET'),
headers: {
// An API key is scoped to one environment and one organization.
Authorization: `Bearer ${process.env.MATINEE_API_KEY}`,
'X-Organization-ID': process.env.MATINEE_ORG_ID!,
'X-Environment': 'sandbox',
// Echoed back on every response and every error. Log it — it is what
// support will ask for.
'X-Request-ID': crypto.randomUUID(),
'Content-Type': 'application/json',
// Required on writes that declare idempotency. Reusing a key with a
// different body is rejected as idempotency_key_reused rather than
// quietly applying the second one.
...(options.idempotencyKey ? { 'Idempotency-Key': options.idempotencyKey } : {}),
},
body: options.body ? JSON.stringify(options.body) : undefined,
})
// Every response carries the same envelope: { data, meta }.
const payload = await response.json()
if (!response.ok) throw new ApiError(payload.error, payload.meta.request_id)
return payload.data as T
}Headers are §4 of the API specification. The response envelope is §5 — success and failure both carry meta.request_id.
Example one
A retail reward loop
Points for spend. Create the customer and their wallet once, define the rule once, then send a purchase event for every order. Requires customers.create, wallets.view, assets.edit, rewards.edit, rewards.activate and events.write.
// 1. The customer, and somewhere to hold what they earn.
const customer = await call('/customers', {
idempotencyKey: `customer-${order.buyerId}`,
body: { external_id: order.buyerId, email: order.email, country: 'GB' },
})
const wallet = await call('/wallets', {
idempotencyKey: `wallet-${customer.id}`,
body: { customer_id: customer.id, type: 'customer' },
})
// 2. The asset being earned. Created configured, then activated — an asset
// cannot be issued until it is active.
const points = await call('/assets', {
idempotencyKey: 'asset-store-points',
body: { name: 'Store Points', symbol: 'PTS', type: 'points', decimals: 0 },
})
await call(`/assets/${points.id}/activate`, { idempotencyKey: `activate-${points.id}` })
// 3. The rule: one point per whole pound. Rules are added to a draft version
// of a programme and take effect when that version is published.
const programme = await call('/reward-programs', {
idempotencyKey: 'programme-purchases',
body: { name: 'Purchase rewards', asset_id: points.id },
})
const rule = await call('/reward-rules', {
idempotencyKey: 'rule-per-pound',
body: {
program_id: programme.id,
event_type: 'order.completed',
conditions: [{ field: 'amount_minor', operator: 'gte', value: 100 }],
actions: [{ type: 'issue', asset_id: points.id, amount: { per: 'amount_minor', rate: 0.01 } }],
},
})
// Simulate before publishing. This writes nothing — no ledger transaction and
// no execution row — which is what makes it a simulation and not a test issue.
const simulation = await call(`/reward-rules/${rule.id}/simulate`, {
body: { event: { type: 'order.completed', amount_minor: 4250 } },
})
await call(`/reward-programs/${programme.id}/publish`, {
idempotencyKey: `publish-${programme.id}-v1`,
})
// 4. From here on, this is the only call your checkout makes.
await call('/events', {
idempotencyKey: order.id,
body: {
type: 'order.completed',
external_event_id: order.id,
customer_id: customer.id,
occurred_at: order.completedAt,
data: { amount_minor: order.totalMinor, currency: 'GBP' },
},
})
const balances = await call(`/wallets/${wallet.id}/balances`)Events are deduplicated on organization, environment and external_event_id — sending the same order id twice returns the original result instead of issuing twice. Retry freely.
Example two
A game economy
The same loop, with the asset spent as well as earned. Redemption is a separate call because it is a different intent: issuing is a reward, redeeming exchanges value for something.
// Earned at the end of a match, from the event the game server already emits.
await call('/events', {
idempotencyKey: match.id,
body: {
type: 'match.completed',
external_event_id: match.id,
customer_id: player.customerId,
data: { placement: match.placement, duration_s: match.durationSeconds },
},
})
// Spent in the store. Direct issuance for a one-off grant that no rule covers —
// a compensation grant, a launch bonus — rather than routing it through an event.
await call(`/assets/${currency.id}/issue`, {
idempotencyKey: `compensation-${incident.id}-${player.customerId}`,
body: { wallet_id: player.walletId, amount: '500', reason: 'Server downtime compensation' },
})
// Redeeming for an in-game item. Fails closed: insufficient_balance if the
// player cannot afford it, asset_paused if the economy is frozen.
await call(`/assets/${currency.id}/redemptions`, {
idempotencyKey: `purchase-${cart.id}`,
body: { wallet_id: player.walletId, amount: item.price, reference: item.sku },
})
// Player-to-player trade, if the economy allows it. Wallet policies are checked
// here, not in your code.
await call(`/assets/${currency.id}/transfers`, {
idempotencyKey: `trade-${trade.id}`,
body: { from_wallet_id: seller.walletId, to_wallet_id: buyer.walletId, amount: trade.amount },
})Nothing here touches a blockchain. An asset lives on internal infrastructure until it is explicitly deployed, and deployment is a separate, confirmed action.
Example three
A membership programme
Tiers are a rule over an event stream rather than a field on a customer. Renewals issue credits; the segment a customer falls into follows from what they hold.
// A renewal is an event like any other. The rule attached to it issues the
// credits for the tier that was bought.
await call('/events', {
idempotencyKey: subscription.invoiceId,
body: {
type: 'subscription.renewed',
external_event_id: subscription.invoiceId,
customer_id: member.customerId,
data: { tier: subscription.tier, months: subscription.termMonths },
},
})
// What a member currently holds, for the account page.
const balances = await call(`/wallets/${member.walletId}/balances`)
// What they have earned and when, for the activity feed. Cursor pagination —
// there is no offset parameter anywhere in the API.
const history = await call(
`/reward-executions?customer_id=${member.customerId}&limit=25`,
)
const next = history.meta?.next_cursor
? await call(`/reward-executions?customer_id=${member.customerId}&cursor=${history.meta.next_cursor}`)
: null
// Segments are derived, not set. Read them; do not try to write them.
const segments = await call(`/customers/${member.customerId}/segments`)Balances are served from a snapshot rather than recomputed per request. Ledger entries remain the source of truth — the snapshot is a cache over them.
Webhooks
Receiving what happened
Rewards are issued asynchronously, so the event you post and the balance that results are two moments. Webhooks are how you learn about the second one. Requires developers.webhooks.manage to register an endpoint.
// The signing secret is returned once, when the endpoint is created, and
// never again. Store it before you discard the response.
const endpoint = await call('/webhooks', {
idempotencyKey: 'webhook-rewards',
body: {
url: 'https://example.com/hooks/matinee',
events: ['reward.issued', 'asset.paused', 'wallet.frozen'],
},
})
// Verify before you trust. Compare in constant time — a plain === leaks the
// signature a byte at a time to anyone willing to measure.
import { createHmac, timingSafeEqual } from 'node:crypto'
export function verify(rawBody: string, signature: string, secret: string) {
const expected = createHmac('sha256', secret).update(rawBody).digest()
const received = Buffer.from(signature, 'hex')
return expected.length === received.length && timingSafeEqual(expected, received)
}
// Handlers must be idempotent. A webhook can arrive more than once, and the
// same delivery can overlap with your own poll of the same resource.An event that failed to process can be reprocessed with POST /events/{id}/replay rather than re-sent from your side, which would collide with deduplication.
Error handling
The failures worth writing code for
Every error carries a stable code, a type and the request id. These are the ones an integration meets in normal operation rather than only in a bad deploy:
- asset_paused — the asset is frozen, so nothing can be issued against it. Not retryable until someone resumes it.
- insufficient_balance — the wallet does not hold enough. Expected on redemption; surface it, do not retry it.
- wallet_frozen — the wallet cannot transact at all. Distinct from a balance problem and needs a different message.
- asset_supply_exhausted — issuing this amount would exceed the supply ceiling. A capacity problem, not a request problem.
- idempotency_key_reused — the same key arrived with a different body. Your key is not unique enough.
- rate_limit_exceeded — back off and retry. The only code in this list where retrying unchanged is the right response.
- organization_mismatch and environment_mismatch — the key does not belong to the organization or environment you named. A configuration error, never a transient one.
Retry on rate_limit_exceeded and on 5xx. Everything else above will fail the same way a second time, and retrying it only delays the message the caller needs to see.
Next
Where to go from here
Building one of these?
Access is reviewed. Tell us which loop you are building and we will let you know when the sandbox opens.