Skip to content

Authentication

Every request to /v1/partners/* carries an API key. Write requests also carry an HMAC signature; read-only GET requests do not have a body to sign and use the API key plus the read-gateway permission.

Authorization: Bearer sk_live_xxxxxxxxxxxxxxxx

On POST requests, add:

x-partner-signature: <hmac-sha256 of the raw body, hex>

The API key says who you are. On writes, the signature proves the body was not altered on the way. A write with a valid key and a wrong signature is refused; the key alone is not enough for writes.

HeaderRequiredWhat it is
AuthorizationyesBearer <api_key>. Created in the console; shown once.
x-partner-signatureon POST requestsHMAC-SHA256 of the raw body, keyed by your signing secret, hex-encoded. Do not send it on GET requests.
X-Idempotency-Keyon money-moving callsSee Idempotency.
x-request-idoptionalAnything 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.

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.

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.

Read-only GET requests use the API key and the endpoint’s permission only:

GET /v1/partners/balance
Authorization: Bearer sk_live_xxxxxxxxxxxxxxxx

Do not send x-partner-signature, a timestamp, or a webhook-style t=... value on a GET. The read endpoints require read-gateway and have no request body or signature string.

Before you send anything real, confirm your signing code produces this exact value:

Secretwhsec_test_secret
Body{"amount":"100000","method":"virtual_account"}
Expected signature703f7d5be76cca886e69d91d948f9c127f5bf03f3bf4a242716df03724edd599
Terminal window
printf '%s' '{"amount":"100000","method":"virtual_account"}' \
| openssl dgst -sha256 -hmac 'whsec_test_secret' -hex

If 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:

PermissionLets a key
collect-paymentCreate payments — virtual accounts, QRIS, e-wallet
read-gatewayQuote payments, read transaction status, and read 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.