Skip to content

Authentication

Every request to /v1/partners/* carries two credentials, and both are required:

Authorization: Bearer sk_live_xxxxxxxxxxxxxxxx
x-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.

HeaderRequiredWhat it is
AuthorizationyesBearer <api_key>. Created in the console; shown once.
x-partner-signatureyesHMAC-SHA256 of the raw body, keyed by your signing secret, hex-encoded.
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.

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-gatewayRead 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.