API authentication

Every 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.

TL;DR

For every request:

  1. Compute bodyHash = sha256(request body in bytes) — empty string for GET/DELETE.
  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 AKIARPLO<random> Public (safe to log)
Secret <random> Secret — shown once

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.

The signing recipe

1. Compute the body hash

Hash the exact bytes of the request body you're going to send, using SHA-256:

bodyHash = hex(sha256(body))

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

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

For POST/PUT/PATCH with a JSON body, hash the serialized JSON — the same bytes you put on the wire.

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, not the access key ID, as the HMAC key.

4. Build the headers

Two headers go on every request:

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

The scope=* field is for future use (per-key permission scoping). For now, always use scope=*.

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. Looks up the partner workspace anchored to acc_<merchantAccountId>.
  3. Rescopes req.auth.accountId to that workspace before route handlers run.
  4. Stamps both the partner ID (from the key) and the on-behalf-of ID into the audit log entry.

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

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: AKIARPLO00000000
  • Secret: secret-from-the-portal
  • Timestamp: 1715526783
  • Body: {"code":"WELCOME10","type":"percent","value":10,"currency":"IDR","scope":"all"}

Step 1: body hash

sha256('{"code":"WELCOME10","type":"percent","value":10,"currency":"IDR","scope":"all"}')
  = "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("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=AKIARPLO00000000, scope=*, signature=7c4f1a2d3b4c5d6e7f8a9b0c1d2e3f405162738495a6b7c8d9e0f1a2b3c4d5e6
X-Ripllo-Timestamp: 1715526783
Content-Type: application/json

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

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=AKIARPLO...
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":"all"}'

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 bytes don't match — you hashed pretty-printed JSON but sent compact, or vice versa.

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.

Next