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:
- The portal's session cookie — how the Ripllo dashboard talks to its own backend. Not for third-party use.
X-Ripllo-Internal-Secret+X-Ripllo-Account-Id— the internal proxy path, only inside our own deployment.Authorization: Ripllo-HMAC-SHA256 …— this page.Authorization: Bearer <Huudis access token>— a direct Huudis JWT, which is whatripllo auth loginin 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:
- Compute
bodyHash = sha256(compact JSON of the request object)— the empty string forGET/DELETEand for an empty body. - 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 | 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 gets401 BAD_SIGNATUREon 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.parse→JSON.stringifyround 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 assha256("{}").
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 (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:
- Verifies the HMAC signature against the platform key's secret (same as always).
- Checks that the key holds
ripllo:platform:admin. - Rescopes
req.auth.accountIdto 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_*, 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:
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 issha256(""). - 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
- 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.