API keys and HMAC signing
How to issue a MeerPartners API key, the postback/sso/readonly scopes, rotation and revocation. The exact HMAC-SHA256 algorithm for signing server-to-server requests, with a worked example.
An API key (tenant_api_key) is an "identifier + secret" pair for a business's server-to-server integrations. The identifier (key_id) is sent openly in a header, while the secret is used only to compute the HMAC signature and is never sent in the request.
Where to issue a key
Keys are issued in the Business cabinet → the Integration section. Only the Owner role (merchant_owner) can manage them.
/api/v1/merchant/integration/keys🔒 Bearer JWTUnder the hood the cabinet calls this endpoint. On creation you specify a name and a list of scopes:
{ "name": "prod backend", "scopes": ["postback", "sso"] }{
"id": 12,
"key_id": "key_3f9a1c",
"name": "prod backend",
"scopes": ["postback", "sso"],
"secret": "sk_live_9f2a…",
"secret_tail": "…a1c",
"warning": "Save the secret — it will not be shown again",
"created_at": "2026-06-14T00:00:00Z"
}The secret cannot be recovered
The secret field is returned only at creation time (and on an explicit "show secret" request by the owner). The database stores not the secret itself but its encrypted form. If you lose the secret, issue a new key and revoke the old one. Copy the secret into your secret store immediately.
Scopes
A scope limits what a key can do. Three values are allowed:
| Scope | What it allows | Where it is needed |
|---|---|---|
postback | Send conversions | Postback — POST /api/v1/postback |
sso | Exchange your user id for an affiliate token | Token-exchange |
readonly | Reserved for reading data through the key | — |
You must specify at least one scope when issuing a key. An attempt to send a postback with a key lacking postback → TENANT_API_KEY_SCOPE_DENIED (403); the same for token-exchange without sso.
Least privilege
Grant a key only the scopes it needs, and create separate keys for separate tasks (for example, one for postback only, another for sso only). Compromising a narrowly scoped key does less damage, and revoking it does not break the other integrations.
Rotation and revocation
/api/v1/merchant/integration/keys/{key_id}/revoke🔒 Bearer JWTRevocation makes the key invalid immediately: any subsequent request signed with it → TENANT_API_KEY_INVALID (401).
Zero-downtime rotation is not a separate button but a procedure:
Issue a new key
Create a second key with the same scopes. Both are now active.
Switch the backend over
Update your server's config to the new key_id and secret. Make sure the postback goes through (use the test postback).
Revoke the old key
Once traffic fully flows through the new key, revoke the old one.
The key list (GET /api/v1/merchant/integration/keys) shows only metadata: key_id, name, scopes, status, the last 4 characters of the secret (secret_tail), and the creation and last-used timestamps. The secrets themselves are not in the list.
The HMAC signing algorithm
All server-to-server calls (postback and token-exchange) are signed the same way. The algorithm is identical for both endpoints.
Request headers
X-Api-Key-Id: key_3f9a1c
X-Tenant-Id: 17 # optional; if set, must match the key's tenant
X-Timestamp: 1717200000 # current time in epoch seconds
X-Signature: 9b2f…c1 # hex(HMAC_SHA256(secret, signing_string))About X-Tenant-Id
The tenant is always resolved from the key itself in the database (anti-IDOR), so X-Tenant-Id is optional. But if you do send it, it must exactly match the tenant the key belongs to — otherwise the postback is rejected with POSTBACK_TENANT_MISMATCH (403). This is a convenient safeguard against accidentally sending data to the wrong business.
The signing string
The signature is computed not over the whole HTTP request but over a canonical string of four parts separated by the newline character \n:
{METHOD}\n{PATH}\n{X-Timestamp}\n{SHA256_hex(raw_body)}| Part | What to substitute |
|---|---|
{METHOD} | The HTTP method in uppercase, e.g. POST |
{PATH} | The request path without host or query, e.g. /api/v1/postback |
{X-Timestamp} | The same value as in the X-Timestamp header (epoch seconds) |
{SHA256_hex(raw_body)} | SHA-256 of the raw bytes of the body in hex (lowercase) |
Then:
X-Signature = hex( HMAC_SHA256( secret, signing_string ) )Sign the exact bytes you send
The hash is computed over the raw body of the request (raw_body). If you serialize JSON, compute the signature, and then a library re-parses and re-serializes the body (different whitespace/key order) — the bytes change and the signature will not match. Serialize the body once, compute the signature over the resulting string, and send exactly that. For requests without a body, hash the empty string.
Anti-replay
X-Timestampmust be within ±300 seconds of the server time. Otherwise, for a postback —POSTBACK_STALE_TIMESTAMP(400). Sync the server clock via NTP.- Each signature is single-use within the memory window (~600 s): resending the same signature →
POSTBACK_REPLAY(409). Do not retry a request with the same signature — build a fresh one with a currentX-Timestamp.
Worked example
Pseudocode and real implementations in two languages. The body here is compact JSON with no extra whitespace; it is important to sign exactly the bytes that will go over the wire.
body = '{"external_order_id":"ORD-558123","event_type":"sale","ref":"…"}'
ts = "1717200000"
method = "POST"
path = "/api/v1/postback"
body_hash = sha256_hex( bytes(body) )
signing = method + "\n" + path + "\n" + ts + "\n" + body_hash
signature = hmac_sha256_hex( key = secret, msg = signing )
# Headers:
# X-Api-Key-Id: <key_id>
# X-Timestamp: ts
# X-Signature: signature