Authentication
Every request to /v1/partners/* carries two credentials, and both are required:
Authorization: Bearer sk_live_xxxxxxxxxxxxxxxxx-partner-signature: <hmac-sha256 of the raw body, hex>The API key says who you are. The signature proves the body was not altered on the way. A request with a valid key and a wrong signature is refused — the key alone is not enough.
| Header | Required | What it is |
|---|---|---|
Authorization | yes | Bearer <api_key>. Created in the console; shown once. |
x-partner-signature | yes | HMAC-SHA256 of the raw body, keyed by your signing secret, hex-encoded. |
X-Idempotency-Key | on money-moving calls | See Idempotency. |
x-request-id | optional | Anything you like; echoed back so you can correlate logs. |
Your API key and your signing secret are different values, and you create both yourself in the console. The key identifies you; the secret signs. Losing one does not cost you the other.
Getting your signing secret
Section titled “Getting your signing secret”One secret works in both directions: you sign your requests with it, and Moria signs the events it sends you with the same value. So you need it before your first call — not just before your first webhook.
It appears in exactly two places, both of them the moment it is created:
- when you save your first callback URL, under Webhook → Alamat pengiriman
- whenever you issue a new one, under Webhook → Signing secret
There is no endpoint and no screen that returns an existing secret. If you lose it, issue a new one — that is the only way, and it is deliberate: a page that could show you a live credential could show it to anyone who reached your open tab.
Signing a request
Section titled “Signing a request”HMAC-SHA256 over the raw bytes of the body, keyed by your signing secret, as lowercase hex.
const body = JSON.stringify(payload) // send THIS string
const signature = crypto .createHmac('sha256', signingSecret) .update(body, 'utf8') .digest('hex')
await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${apiKey}`, 'x-partner-signature': signature, 'X-Idempotency-Key': idempotencyKey, }, body, // the exact same string})If your framework re-serialises bodies automatically, reach for the raw-body escape hatch. In Express that is express.raw(); in NestJS, rawBody: true.
Checking your implementation
Section titled “Checking your implementation”Before you send anything real, confirm your signing code produces this exact value:
| Secret | whsec_test_secret |
| Body | {"amount":"100000","method":"virtual_account"} |
| Expected signature | 703f7d5be76cca886e69d91d948f9c127f5bf03f3bf4a242716df03724edd599 |
printf '%s' '{"amount":"100000","method":"virtual_account"}' \ | openssl dgst -sha256 -hmac 'whsec_test_secret' -hexIf your output differs, the problem is in how you build the string — not in your key, and not at our end.
Keys are created in the console, under API Key. The value is shown once, at creation — Moria stores only a hash of it, so there is no screen and no endpoint that can show it to you again. Copy it into your secret store at that moment.
Choose the narrowest permissions that work:
| Permission | Lets a key |
|---|---|
collect-payment | Create payments — virtual accounts, QRIS, e-wallet |
read-gateway | Read transaction status and balance |
A key that can request payouts cannot be created from the console. It is issued by Moria, deliberately: a payout credential is a durable bearer token that moves money with nobody signed in, and that is not something a browser session should be able to mint.
Revoking a key takes effect on the next request. The row stays, with its usage counters — those are the evidence you read after a leak.