delivery_mode that decides what happens the moment a customer spends their points:
The mode is set per reward, so one business can mix all three.
Which mode for which business
- A café, salon, gym — anywhere with a counter.
in_store. The customer redeems in the app, shows the code, your staff (or your POS integration) completes it. Points are only spent once the reward is actually handed over. - An online store shipping physical goods.
shipping. A limited-run shirt, a sample box, branded merch. The customer submits their address in the app; you get a fulfillment queue over the API. - A brand with no storefront, or anything deliverable as text.
digital. A discount code for your checkout, a gift-card code, a link to a private download or a members-only page. Nothing to ship, nothing to staff. One value for everyone, a batch of one-time codes, or a code minted per customer — see provisioning sources.
shipping and digital deduct the points at redemption and create the redemption already completed — there is no code to validate afterwards. That’s the tradeoff for delivering without a terminal: the customer commits their points up front.In store
Unchanged, and still the default — create a reward withoutdelivery_mode and you get exactly today’s behavior. The customer redeems in the app, gets an 8-character code (or a QR), and you consume it at checkout. Full walkthrough: Issue points & validate rewards.
Digital
Configure what the customer receives on the reward itself:- The
digital_*fields are only accepted on digital rewards — send them together withdelivery_mode: "digital"or not at all.digital_asset_typeis always required. - Editing the reward never rewrites what someone already received. The value is snapshotted onto each redemption, so changing
digital_asset_valuenext month only affects redemptions from that point on. digital_asset_valueanddigital_webhook_urlare returned on your own reward reads. They are never exposed on customer-facing payloads.
Digital provisioning sources
The example above is the simplest kind of digital reward: one code, handed to everyone.digital_asset_source changes where the value comes from — and
nothing else. delivery_mode stays digital, the customer still gets their code
or link instantly in the app, and the points still leave their balance at
redemption. Only your side of the wall changes.
Pick by who owns the codes. If you already hold the list, upload it as a
pool — nothing to build, nothing to keep online. If the codes only exist once
something asks for them, go external. static is for the case where there is
genuinely only one value.digital_webhook_secret is not in that list, and never will be — see
the signing secret.
Static values
What the example above does, and the default if you never senddigital_asset_source. One value in digital_asset_value, the same for every
customer, changed whenever you like. Rigaly does not generate it and does not
track how many times it has gone out — that is what max_redemptions is for.
Pool of one-time values
A pool is a stored batch of values belonging to one reward. Each redemption consumes exactly one, atomically — the claim happens inside the database, so two customers redeeming at the same instant can never be handed the same value. There is no window in which a code is “reserved but not yet spent” for two people at once. The lifecycle:1. Create the reward
Senddigital_asset_source: "pool" and no digital_asset_value — the pool
supplies it.
active: false,
deactivated_by_pool: true. There is nothing to hand out yet, so asking for
active: true doesn’t fail; it is simply parked, and the first upload opens
it. Create it with active: false instead and it stays off until you say
otherwise, upload or no upload.
2. Upload the values
Up to 5,000 values per request, each 1–500 characters, up to a capacity of 100,000 stored values per reward. Send as many requests as the list needs.Uploading is idempotent by value. A value the pool already holds is counted
and skipped, never stored twice — so re-sending a file you’re not sure went
through can’t double-load your codes. If nothing in the request was usable you
get
POOL_UPLOAD_EMPTY; if it would push total past capacity you get
POOL_CAPACITY_EXCEEDED:<max> and nothing is stored.3. Watch it drain
available figure rides along on every reward read as pool_remaining,
so a dashboard that already lists rewards doesn’t need a second call.
4. Empty means off — refilling turns it back on
Atavailable: 0 the reward switches itself off: active: false,
deactivated_by_pool: true, and it disappears from the customer app. Upload
more values and it comes back on by itself.
This is the same mechanic as the redemption cap, and the two are entirely
independent:
Both can be true at once — a reward that hit its cap and emptied its pool. It
only comes back when neither is blocking: refilling a pool on a reward that is
still at its cap leaves it off (and vice versa). A reward you switched off by
hand stays off regardless, and toggling it back on by hand clears both flags —
provided there is stock and headroom to do it with.
5. Deleting and exporting
Delete unconsumed values — the ones you name, or all of them when you send no body:format=json streams the same rows as an array of
{ value, status, consumed_at, redemption_id }. Both formats include consumed
and available values, oldest first.
External provisioning
Setdigital_asset_source: "external" and give Rigaly an HTTPS endpoint. At
redemption time Rigaly POSTs to it, your service mints a value, and whatever it
returns is what the customer sees — a code tied to that customer, a signed
download link, a licence generated on the spot.
The signing secret
Creating a reward asexternal, or switching an existing reward to external,
returns digital_webhook_secret on the reward object — once. No read endpoint
ever returns it. Editing only the URL of an already-external reward does not
rotate it, so an endpoint move needs no redeploy of your verification code.
Lost it, or rotating on a schedule? Issue a new one:
The request Rigaly sends
OnePOST, Content-Type: application/json, per redemption:
GET /api/v1/customers/{user_id}/balance or your own record keyed on user_id.
Verifying the signature
X-Rigaly-Signature is t=<unix>,v1=<hex>, where the hex is:
What you must return
Any2xx, with a JSON body:
Anything else — a non-2xx, a missing field, a type mismatch, an
expires_at in
the past — fails the provisioning call.
The operational envelope
Design your endpoint to fit inside it:Testing before you ship
POST /rewards/{id}/webhook/test runs the real signed call against your endpoint
without creating a redemption, moving points or charging a fee. The body carries
test: true — return a throwaway value, not real stock.
The test endpoint answers HTTP 200 even when the test failed. A transport
failure on your side isn’t an API error on ours, so read
ok — never the status
code — and expect error_code / error to explain the rest.error_code is one of timeout, network, http_status,
malformed_response, type_mismatch, expired_asset, circuit_open, or an
ssrf_* code when the URL or the address it resolves to is refused.
request_preview is the exact payload that was signed, field for field — check
it against what your handler received when a signature won’t verify.
Nobody pays for a failure
Across all three sources, points are deducted only after the value is secured. The redemption is opened, the value is claimed or minted, and only then does the customer’s balance move. So when provisioning fails:- the redemption is cancelled,
- no points are taken,
- any pool value that was claimed is released back to the pool,
- and no per-transaction fee is charged.
Shipping
Create the reward with the mode and, usually, a redemption cap — shipped rewards are almost always limited stock:completed with fulfillment_status: "pending". From there it’s your queue.
1. Poll the queue
ship_to_name, ship_to_phone, ship_to_email, ship_to_address), the reward (reward_id, reward_name, reward_image_url, points_cost), the customer (user_id, user_name) and the lifecycle timestamps. Only shipping redemptions appear here — in-store and digital ones are not fulfillment work. Filter by reward_id to work one product at a time, and page with page / limit.
2. Advance the status
Use the redemption’sid (not its code). Send a tracking_reference with the shipped step so the customer can follow the parcel in the app:
shippedstampsshipped_at.deliveredstampsdelivered_at, and backfillsshipped_atif you skipped the middle step.cancelledends the order, refunds the customer’s points, moves the redemption’s ownstatustocancelled, and releases the reward’s redemption slot — if the reward had been closed by its cap, it reopens. Cancelling a second time returnsFULFILLMENT_ALREADY_CANCELLED.
3. Cancelling refunds the points
Out of stock, undeliverable address, customer changed their mind — cancelling is the way out, and the points the customer spent go straight back to their balance. You don’t have to credit them yourself.points_refunded is null when nothing was owed — a free reward, a customer whose account has since been anonymised, or a redemption that is no longer completed because an earlier call already refunded it. It is also always null on shipped and delivered; only a cancellation moves points.
Cancelling is atomic and idempotent. The refund and the status change happen inside one database function, so retrying a cancel that already went through pays out nothing a second time — you get
FULFILLMENT_ALREADY_CANCELLED, never a double credit. Retry freely on a timeout.Cancelling without a refund
Sendrefund_points: false when the points should not come back:
points_refunded: null.
The reward’s redemption slot is released either way: refund_points only decides whether the customer’s balance moves.
Capping how many go out
Any reward, in any mode, takes amax_redemptions ceiling — the total number of completed redemptions allowed across all customers. null (the default) means unlimited.
active: false, deactivated_by_limit: true — and disappears from the customer app. Raise the cap (or set it to null) and it comes back on automatically; a reward you switched off by hand stays off, whatever you do to its cap. Lowering the cap below redemptions_count is rejected with REWARD_LIMIT_BELOW_CURRENT:<count>.
Cancelling a shipping fulfillment gives its slot back: redemptions_count drops, redemptions_remaining rises, and a reward the cap had closed reopens on its own — while the customer gets their points back by default. A cancelled order costs you neither the stock nor the goodwill.
In-store codes are handed out before the points are spent, so a popular limited reward can be over-claimed: more customers can be holding a pending code than you have stock. Those codes are rejected at the counter with
REDEMPTION_LIMIT_REACHED:<max>. Shipping and digital rewards don’t have this window — they complete on the spot.Errors
Digital provisioning adds these:
Full list: Errors.