Skip to main content
Every reward has a 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 without delivery_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:
Rules worth knowing:
  • The digital_* fields are only accepted on digital rewards — send them together with delivery_mode: "digital" or not at all. digital_asset_type is always required.
  • Editing the reward never rewrites what someone already received. The value is snapshotted onto each redemption, so changing digital_asset_value next month only affects redemptions from that point on.
  • digital_asset_value and digital_webhook_url are 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.
Reward reads report the source and its state:
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 send digital_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

Send digital_asset_source: "pool" and no digital_asset_value — the pool supplies it.
A brand-new pool reward always comes back switched offactive: 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.
The response is the full accounting — what happened to your values, and where the pool now stands:
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

The same 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

At available: 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:
Consumed values can never be deleted or edited. They are what a customer already received, so they stay on the pool permanently — they keep counting toward total and consumed, and a value you ask to delete that had already gone out simply won’t appear in the response’s deleted count.
Export the whole pool for reconciliation — which code went to which redemption:
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

Set digital_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 as external, 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 previous secret stops working immediately. Deploy the new one before you rotate, or accept a gap in which your endpoint rejects Rigaly’s signature — those redemptions fail, and while no customer is charged for them, they do see the reward refuse to deliver.

The request Rigaly sends

One POST, Content-Type: application/json, per redemption:
IDs only, never PII. No name, no email, no phone. If your provisioning logic needs the customer’s details, look them up with 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:
The Stripe convention, and the same three rules apply: sign the raw bytes (re-serializing the parsed JSON will not reproduce the digest), compare in constant time, and reject stale timestamps so a captured request can’t be replayed at leisure.

What you must return

Any 2xx, 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 same Idempotency-Key must always return the same value. Store the key alongside the value you minted and replay it verbatim on a repeat call; never mint a second one. A network blip that makes Rigaly’s request look failed to you but succeeded to us, a retry you trigger yourself, a duplicate delivery — all of them are harmless if and only if the key is your unit of truth. Get this wrong and you hand out two codes for one redemption.

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.
The customer sees the reward fail to deliver and their balance untouched, which is the only acceptable outcome for a code you couldn’t produce. They can try again once you’ve fixed it.

Shipping

Create the reward with the mode and, usually, a redemption cap — shipped rewards are almost always limited stock:
When a customer redeems it, the app collects their name, phone, email and address, spends the points, and creates the redemption as completed with fulfillment_status: "pending". From there it’s your queue.

1. Poll the queue

Each row carries the shipping details (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’s id (not its code). Send a tracking_reference with the shipped step so the customer can follow the parcel in the app:
The lifecycle:
  • shipped stamps shipped_at.
  • delivered stamps delivered_at, and backfills shipped_at if you skipped the middle step.
  • cancelled ends the order, refunds the customer’s points, moves the redemption’s own status to cancelled, and releases the reward’s redemption slot — if the reward had been closed by its cap, it reopens. Cancelling a second time returns FULFILLMENT_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.
The response tells you exactly what was paid out:
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

Send refund_points: false when the points should not come back:
Reach for it when you’ve already made the customer whole another way — you shipped a replacement item, issued store credit, or refunded cash — and a points refund on top would pay them twice. Also useful when you’re closing out an order the customer already received and kept, and you only want the slot and the paperwork cleaned up. The response comes back with points_refunded: null. The reward’s redemption slot is released either way: refund_points only decides whether the customer’s balance moves.
Cancelling does not reverse billing. The per-transaction fee charged when the customer originally claimed the reward stands — refunding the points does not refund the fee. Cancellations are a customer-service action, not a billing reversal.

Capping how many go out

Any reward, in any mode, takes a max_redemptions ceiling — the total number of completed redemptions allowed across all customers. null (the default) means unlimited.
Reward payloads report where the ceiling stands:
At the ceiling the reward switches itself off — 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.