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:
- Compute
bodyHash = sha256(request body in bytes)— empty string forGET/DELETE. - Build a string-to-sign:
METHOD\npath\ntimestamp\nbodyHash[\nidempotencyKey] signature = HMAC-SHA256(secret, stringToSign)— hex-encoded.- 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-codes — strip 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 get401 BAD_SIGNATUREon every paginatedGET. 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:
- Verifies the HMAC signature against the platform key's secret (same as always).
- Looks up the partner workspace anchored to
acc_<merchantAccountId>. - Rescopes
req.auth.accountIdto that workspace before route handlers run. - 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_*, neverusr_*. 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
- API overview — the resource map.
- SDKs — if you'd rather not implement signing yourself.
- Authentication overview — portal-side OIDC, the other half of the auth story.