Currency

API authentication

Every authenticated Ripllo API request must be signed. We use HMAC-SHA256 request signing with a key ID + key secret pair you mint in the dashboard. This is the same scheme Plugipay and Fulkruma use — if you've integrated those, the recipe is identical (only the header prefix differs).

This page covers the exact recipe with a worked example. If you're using one of our SDKs, signing is automatic — you only need this page if you're integrating directly over HTTP.

What else the server accepts

HMAC is the credential for server-to-server integrations, but it isn't the only one the API accepts. In order of precedence:

  1. The portal's session cookie — how the Ripllo dashboard talks to its own backend. Not for third-party use.
  2. X-Ripllo-Internal-Secret + X-Ripllo-Account-Id — the internal proxy path, only inside our own deployment.
  3. Authorization: Ripllo-HMAC-SHA256 … — this page.
  4. Authorization: Bearer <Huudis access token> — a direct Huudis JWT, which is what ripllo auth login in the CLI produces. Verified against the Huudis issuer/audience.

Endpoints that need no credentials

A handful of storefront-facing reads are deliberately open, because a public storefront has no key to sign with:

Method Path Returns
GET /api/v1/blog/public/:accountId Published posts for a merchant
GET /api/v1/blog/public/:accountId/:slug One published post by slug
GET /api/v1/pixels/public/:accountId Public pixel IDs (never the CAPI token)
GET /api/v1/feeds/google/:accountId.xml The Google Merchant Center feed
GET /api/v1/discount-codes/applicable/:accountId Active public codes for a cart context
GET /api/v1/abandoned-cart/unsubscribe The one-click email opt-out page (no key, but the link carries a signed token)
GET /api/v1/health Liveness

Everything else — including POST /discount-codes/validate and /redeem, the referrals partner routes and abandoned-cart /reminders + /recover — requires a credential. Those partner routes take the merchant from the signed principal, not from accountId in the body.

TL;DR

For every request:

  1. Compute bodyHash = sha256(compact JSON of the request object) — the empty string for GET/DELETE and for an empty body.
  2. Build a string-to-sign: METHOD\npath\ntimestamp\nbodyHash[\nidempotencyKey]
  3. signature = HMAC-SHA256(secret, stringToSign) — hex-encoded.
  4. Send two headers:
    • Authorization: Ripllo-HMAC-SHA256 keyId=<id>, scope=*, signature=<hex>
    • X-Ripllo-Timestamp: <epoch seconds>

The key pair

Generate an API key in Settings → API keys. You'll get two values:

Field Format Visibility
Access key ID AKIAFULK<16 hex chars> Public (safe to log)
Secret fulksk_<base64url> Secret — shown once

The AKIAFULK / fulksk_ prefixes come from the shared Forjio key generator rather than from Ripllo's own brand — if the key you copied out of the portal doesn't start with AKIARPLO, nothing is wrong. Match on what the portal showed you.

Each key also carries scopes: read covers GET/HEAD, write covers every mutation, and keys default to both. Partner keys additionally hold ripllo:platform:admin, which is a superset. Scopes are enforced on every signed request — a key without the one the method needs gets 403 INSUFFICIENT_SCOPE.

The secret appears only once. When you create a key, Ripllo shows the secret in a dialog. If you close it without copying, you have to mint a new key. There's no recovery flow.

Known issue — keys minted in the portal can't sign yet. The key-creation path stores sha256(secret) in the column the signature check uses as the HMAC key, while this recipe (and every Ripllo SDK) signs with the secret itself. Until that is corrected in the backend, a portal-minted key gets 401 BAD_SIGNATURE on every request, no matter how correct your signing code is. If you hit this, don't start rewriting your signer — contact support.

The signing recipe

1. Compute the body hash

Hash the compact JSON serialization of the request object — JSON.stringify(body), no whitespace, no pretty-printing:

bodyHash = hex(sha256(JSON.stringify(body)))

For GET and DELETE (or any request without a body), use the empty string:

bodyHash = hex(sha256("")) = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"

Two consequences of how the server rebuilds this, which cost people hours if they don't know:

  • The server does not hash your wire bytes. It parses the JSON body, re-serializes it compactly, and hashes that. So pretty-printing your body and honestly hashing those same pretty-printed bytes still fails — send compact JSON and hash compact JSON. Key order must also survive a JSON.parseJSON.stringify round trip, which in practice means: build the object once, stringify it once, send that exact string.
  • An empty object counts as no body. A body of {} hashes as the empty string, not as sha256("{}").

2. Build the string-to-sign

Four (or five) fields joined by literal \n (newline):

METHOD\n
path\n
timestamp\n
bodyHash\n
idempotencyKey       (only if you're sending the Idempotency-Key header)
Field Example
METHOD POST (uppercase)
path /api/v1/discount-codesstrip the query string before signing
timestamp 1715526783 (current epoch seconds; must be within 300 seconds of server time)
bodyHash hex SHA-256 of the body
idempotencyKey the exact value of the Idempotency-Key header, if present

Sign the path without the query string. The server strips ?limit=... before reconstructing the string-to-sign. If you include it on your side, you'll get 401 BAD_SIGNATURE on every paginated GET. The Node SDK had this exact bug latent in early versions — it only surfaced when the first paginated call landed.

A POST /api/v1/discount-codes looks like:

POST
/api/v1/discount-codes
1715526783
b5d4045c3f466fa91fe2cc6abe79232a1a57cdf104f7a26e716e0a1e2789df78

A GET /api/v1/discount-codes?limit=10 (no body, no idempotency key, query string stripped):

GET
/api/v1/discount-codes
1715526783
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855

3. Compute the signature

signature = hex(HMAC-SHA256(secret, stringToSign))

Use the secret (fulksk_…), not the access key ID, as the HMAC key.

4. Build the headers

Two headers go on every request:

Authorization: Ripllo-HMAC-SHA256 keyId=AKIAFULK<hex>, scope=*, signature=<hex>
X-Ripllo-Timestamp: 1715526783

The scope=* field in the header is parsed but not used for authorization — always send scope=*. The permissions that actually apply are the scopes stored on the key, which the server reads from its own record; you can't widen them from the client side.

If your request has a body, also send:

Content-Type: application/json

If you're sending an idempotency key, add it (the value here must match what's in the string-to-sign):

Idempotency-Key: order-2026-05-12-001

Partner billing: X-Ripllo-On-Behalf-Of

If your key has the ripllo:platform:admin scope, you can act on behalf of a downstream merchant by adding one extra header:

X-Ripllo-On-Behalf-Of: acc_<merchantAccountId>

When Ripllo sees this header on a request signed with an admin-scoped key, it:

  1. Verifies the HMAC signature against the platform key's secret (same as always).
  2. Checks that the key holds ripllo:platform:admin.
  3. Rescopes req.auth.accountId to the header's value before route handlers run.

If a non-admin key sends X-Ripllo-On-Behalf-Of, Ripllo returns 403 FORBIDDEN_ONBEHALF.

Two things it does not do, which matter when you're debugging:

  • The account ID is not validated. Once the admin scope check passes, the header value is taken verbatim — there's no lookup of a partner workspace. A typo'd or unknown account ID doesn't error; it silently scopes the request to an empty workspace, so reads come back empty and writes land somewhere nobody is looking. Validate the ID on your side.
  • The partner identity isn't recorded. Audit log entries carry the acting account, not the partner key or the on-behalf-of pair, so you can't reconstruct "which partner did this" from Ripllo's audit trail today. Keep your own record if you need one.

On partner-facing write endpoints (/discount-codes/redeem, /discount-codes/validate, the referrals routes, abandoned-cart /reminders and /recover) the rescoped account is also the only source of accountId. If you send an accountId in the body that differs from the header, the request is rejected with 403 ACCOUNT_MISMATCH; if you omit it, it's filled in for you.

This is the mechanism Storlaunch uses to read and write merchant marketing data without each merchant needing their own Ripllo API key. The merchant sees their data in their Storlaunch portal; Storlaunch's backend proxies their actions to Ripllo using the platform admin key + this header.

The header always uses acc_*, never usr_*. Partner-provisioned workspaces are anchored on partner account IDs; direct sign-up workspaces are anchored on Huudis user IDs. The two namespaces never cross.

Worked example

Sign a POST /api/v1/discount-codes with these inputs:

  • Access key ID: AKIAFULK0011223344556677
  • Secret: fulksk_secret-from-the-portal
  • Timestamp: 1715526783
  • Body: {"code":"WELCOME10","type":"percent","value":10,"currency":"IDR","scope":"cart"}

Step 1: body hash

sha256('{"code":"WELCOME10","type":"percent","value":10,"currency":"IDR","scope":"cart"}')
  = "8d2c5b3b..."

Step 2: string-to-sign

POST
/api/v1/discount-codes
1715526783
8d2c5b3b...

Step 3: signature (using the secret as the HMAC key)

HMAC-SHA256("fulksk_secret-from-the-portal", stringToSign)
  = "7c4f1a2d3b4c5d6e7f8a9b0c1d2e3f405162738495a6b7c8d9e0f1a2b3c4d5e6"

Step 4: send

POST /api/v1/discount-codes HTTP/1.1
Host: ripllo.com
Authorization: Ripllo-HMAC-SHA256 keyId=AKIAFULK0011223344556677, scope=*, signature=7c4f1a2d3b4c5d6e7f8a9b0c1d2e3f405162738495a6b7c8d9e0f1a2b3c4d5e6
X-Ripllo-Timestamp: 1715526783
Content-Type: application/json

{"code":"WELCOME10","type":"percent","value":10,"currency":"IDR","scope":"cart"}

A complete curl example

ripllo_curl() {
  local METHOD="$1"
  local PATH_QS="$2"
  local BODY="${3:-}"

  local TS=$(date +%s)
  local PATH_NO_QS="${PATH_QS%%\?*}"
  local BODY_HASH=$(printf '%s' "$BODY" | openssl dgst -sha256 | awk '{print $2}')
  local STRING_TO_SIGN="${METHOD}
${PATH_NO_QS}
${TS}
${BODY_HASH}"

  local SIG=$(printf '%s' "$STRING_TO_SIGN" | \
    openssl dgst -sha256 -hmac "$RIPLLO_KEY_SECRET" | \
    awk '{print $2}')

  curl -sS -X "$METHOD" "https://ripllo.com$PATH_QS" \
    -H "Authorization: Ripllo-HMAC-SHA256 keyId=$RIPLLO_KEY_ID, scope=*, signature=$SIG" \
    -H "X-Ripllo-Timestamp: $TS" \
    ${BODY:+-H "Content-Type: application/json"} \
    ${BODY:+-d "$BODY"}
}

export RIPLLO_KEY_ID=AKIAFULK...
export RIPLLO_KEY_SECRET=...

ripllo_curl GET '/api/v1/discount-codes?limit=5'
ripllo_curl POST '/api/v1/discount-codes' '{"code":"WELCOME10","type":"percent","value":10,"currency":"IDR","scope":"cart"}'

Timestamp tolerance

The server rejects requests where X-Ripllo-Timestamp is more than 300 seconds (5 minutes) off server time. This blocks replay attacks: a captured signature is useless 5 minutes later.

If you see 401 INVALID_TIMESTAMP or 401 CLOCK_SKEW:

  • Make sure your system clock is correct.
  • If you're in CI, ensure the runner's clock is in sync.

Common errors

401 BAD_SIGNATURE

The signature didn't match what the server computed. Causes (in order of likelihood):

  • Wrong secret — you copied the access key ID as the secret, or partial copy.
  • Query string included in the signed path — strip everything from ? onwards before signing.
  • Wrong string-to-sign format — extra whitespace, wrong field order, missing newline before idempotency key.
  • Body isn't compact JSON — the server hashes the re-serialized body, so pretty-printed JSON fails even when you hash exactly what you sent. Send JSON.stringify(body).
  • You hashed {} — an empty object is treated as no body, so its hash is sha256("").
  • The key was minted in the portal — see the known issue under The key pair; no client-side change fixes that one.

To debug, log the exact string-to-sign and body bytes on your side. The server doesn't echo them back.

401 INVALID_KEY

The access key ID doesn't exist or is from a different workspace. Verify the ID matches what's in the portal under Settings → API keys.

401 REVOKED_KEY

The key existed but has been revoked. Mint a new one.

401 CLOCK_SKEW

Your timestamp is more than 5 minutes off. Sync the clock.

403 FORBIDDEN_ONBEHALF

You sent X-Ripllo-On-Behalf-Of from a key that doesn't have the ripllo:platform:admin scope. Either use a platform-admin key or drop the header.

403 INSUFFICIENT_SCOPE

The key is valid but lacks the scope this method needs — read for GET/HEAD, write for everything else. Scopes are fixed at creation; mint a new key with the scope you need.

403 ACCOUNT_MISMATCH

On a partner endpoint you sent an accountId in the body that isn't the account you're signed in as (or acting for via X-Ripllo-On-Behalf-Of). Drop it from the body, or make it match the header.

Next

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