Currency

ripllo.discount_code.redeemed.v1

The "a discount code was used for real" signal — it carries the redemption row plus enough context to reconcile against the partner's order log.

Reserved — not currently emitted in v1. Nothing in Ripllo writes a ripllo.discount_code.redeemed.v1 outbox row: POST /api/v1/discount-codes/redeem commits the DiscountRedemption and returns, and no event is built. More broadly, no Ripllo webhook is delivered to any endpoint today — the outbox worker's deliver() step is still a stub that marks rows published without POSTing them anywhere, so even the loyalty events that are written never reach a WebhookEndpoint. Subscribe defensively; for now, trigger off your own redeem() return value or poll GET /api/v1/discount-codes.

When it will fire

When POST /api/v1/discount-codes/redeem commits a new DiscountRedemption row. The Storlaunch partner SDK calls this from its payment-success webhook, so once wired the event would fire within seconds of money landing.

It would be single-shot per redemption: the redemption is idempotent on (accountId, checkoutSessionId), so a partner retry of the same session returns the existing row with 200 OK and would not re-emit.

Payload

{
  "id": "evt_01HX...",
  "type": "ripllo.discount_code.redeemed.v1",
  "occurredAt": "2026-05-13T10:43:22.187Z",
  "accountId": "acc_01HX9C2K3M4N5P6Q7R8S9T0V1W",
  "data": {
    "redemption": {
      "id": "dcr_01HX...",
      "accountId": "acc_01HX...",
      "discountCodeId": "dc_01HX...",
      "checkoutSessionId": "cs_storlaunch_01HX...",
      "customerId": "cus_01HX...",
      "appliedAmount": 25000,
      "appliedShipping": 0,
      "externalSource": "storlaunch",
      "externalRef": "ord_42",
      "createdAt": "2026-05-13T10:43:22.140Z"
    },
    "code": {
      "id": "dc_01HX...",
      "code": "WELCOME10",
      "type": "percent",
      "value": 10,
      "currency": "IDR",
      "scope": "cart",
      "redemptionCount": 87,
      "maxUsesTotal": null
    }
  },
  "metadata": {}
}

The envelope field is occurredAt, not createdAt, and every envelope also carries a metadata object (empty unless the emitter set one). accountId is nullable — platform-level events carry null.

The payload carries both the redemption row and a snapshot of the code at redemption time. redemptionCount (the code's denormalized counter — there is no usesCount field) reflects the count including this redemption. For percent codes, value is a whole percent.

Handler examples

All three SDKs take the Ripllo-Signature header value — a single string shaped t=<unix>,v1=<hex>, signing ${t}.${rawBody} — not the whole headers map. Pass the raw, unparsed body bytes.

// Node
import { verifyWebhook } from '@forjio/ripllo-node';

app.post('/ripllo/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
  const event = verifyWebhook({
    rawBody: req.body,
    signature: req.headers['ripllo-signature'],
    secret: process.env.RIPLLO_WEBHOOK_SECRET,
    // toleranceSec: 300  — optional, this is the default
  });
  if (event.type === 'ripllo.discount_code.redeemed.v1') {
    const { redemption, code } = event.data;
    analytics.track('discount_redeemed', {
      code: code.code,
      orderId: redemption.externalRef,
      discountIdr: redemption.appliedAmount,
    });
    if (code.maxUsesTotal !== null && code.redemptionCount >= code.maxUsesTotal) {
      notifyMerchant(`Code ${code.code} sold out`);
    }
  }
  res.status(200).end();
});
# Python — arguments are keyword-only
from ripllo import verify_webhook

event = verify_webhook(
    raw_body=raw_body,
    signature=request.headers.get('Ripllo-Signature'),
    secret=os.environ['RIPLLO_WEBHOOK_SECRET'],
)
if event['type'] == 'ripllo.discount_code.redeemed.v1':
    redemption = event['data']['redemption']
    code = event['data']['code']
    analytics.track('discount_redeemed',
        code=code['code'],
        order_id=redemption['externalRef'],
        discount_idr=redemption['appliedAmount'])
// Go — pass nil for the options struct to accept the defaults
import ripllo "github.com/hachimi-cat/ripllo-go"

event, err := ripllo.VerifyWebhook(rawBody, r.Header.Get("Ripllo-Signature"), os.Getenv("RIPLLO_WEBHOOK_SECRET"), nil)
if err != nil {
    http.Error(w, "bad signature", http.StatusBadRequest)
    return
}
if event.Type == "ripllo.discount_code.redeemed.v1" {
    var data struct {
        Redemption ripllo.DiscountRedemption `json:"redemption"`
        Code       ripllo.DiscountCode       `json:"code"`
    }
    _ = json.Unmarshal(event.Data, &data)
    analytics.Track("discount_redeemed", data.Code.Code, data.Redemption.ExternalRef, data.Redemption.AppliedAmount)
}

What to do

  • Update analytics. Track discount usage by code, channel (externalSource), and order value. This is the cleanest source of "which campaigns drove revenue".
  • Alert on sold-out codes. When code.maxUsesTotal !== null && code.redemptionCount >= code.maxUsesTotal, the next attempt to validate this code will fail with MAX_USES_REACHED. Notify the merchant if it's a campaign code they didn't expect to exhaust.
  • Reconcile against partner orders. redemption.externalRef is the partner's order ID; pair it with code.code to match what the buyer typed in.
  • Update commission accruals. If you pay affiliates or referrers based on discount-coded sales, the redemption is the trigger.

Common pitfalls

  • Treating redeemedAt as money-in-bank. It's the commit time of the redemption row, immediately after Ripllo's idempotency check. Funds settlement is on Plugipay's timeline, not Ripllo's.
  • Doing work twice on partner retry. Even though Ripllo doesn't double-emit, your own delivery may retry. Dedupe on event.id.
  • Reading code.redemptionCount as authoritative for "remaining slots". Two concurrent redemptions can both observe redemptionCount = N and both commit, so the count can momentarily exceed maxUsesTotal by one or two. Use it as a "roughly how full is the bucket" signal, not a strict guard.
  • Assuming customerId is set. Anonymous checkouts produce redemption rows with customerId: null. Default to "anonymous" in your analytics rather than dropping the row.
  • Trusting externalSource without validating. It's whatever the partner platform set. For the canonical Storlaunch integration, "storlaunch" is the convention, but a custom integration can pass anything.

All three are likewise reserved-not-emitted today:

The only event types Ripllo builds outbox rows for at all are ripllo.loyalty.earned.v1, ripllo.loyalty.redeemed.v1 and ripllo.loyalty.voided.v1 — and those are marked published without being delivered.

Next

CurrencyRupiah is paid by transfer or QRIS; US dollars settle through PayPal.