API reference · for integrating SaaS clients
Client Integration Guide
How your application authenticates, creates and queries payments, and receives webhook events from this payment platform. Every example below matches the running implementation.
- Base URL
- /api/v1
- Auth
- Bearer <api_key>
- Format
- application/json
01Getting set up
Projects, API keys, and provider configuration (Midtrans credentials) are managed by the platform operator through the admin dashboard, not through a self-service API. Ask the platform operator for:
- Your project to be created.
- An API key issued for your project, as either
testorlive(prefixedsk_test_.../sk_live_...). This is fully enforced: atestkey always uses Midtrans sandbox credentials and every payment it creates is taggedenvironment: "test"for its whole lifetime. Test and live traffic can run through the exact same deployment at the same time — no separate staging environment or different base URL needed. - A webhook endpoint registered pointing at your server, plus the webhook secret generated for it (shown once at creation — save it immediately). A webhook endpoint also has a
test/liveenvironment: it only receives deliveries for payments created in that same environment. Register both atestand aliveendpoint up front — whichever matches a given payment is picked automatically, no manual switching later.
The API key is shown once at issuance. It cannot be retrieved again — only revoked and reissued.
02Authentication
Every request to /api/v1/* must include your API key as a bearer token:
Authorization: Bearer sk_live_xxxxxxxxxxxxxxxxxxxxxxxx
Requests without a valid, non-revoked key receive 401 Unauthorized.
03Create a payment
POST /api/v1/payments
Content-Type: application/json
Authorization: Bearer sk_test_xxxxxxxxxxxxxxxxxxxxxxxx
Idempotency-Key: order-123
{
"reference": "ORDER-123",
"amount": 150000,
"currency": "IDR",
"provider": "midtrans",
"customer": {
"name": "Jane Doe",
"email": "jane@example.com"
},
"metadata": {
"order_id": "ORDER-123"
},
"return_url": "https://your-app.example.com/orders/ORDER-123/finish"
}
Field notes
reference— your own order/order-line identifier. Not required to be globally unique (the platform generates its own internal payment id for provider correlation), but should be unique within your own system for your own bookkeeping.amount— a positive number or a numeric string with up to 2 decimal places (e.g.150000or"150000.00"). Never send it as a float you've computed with imprecise arithmetic — send whole units or a pre-formatted string.currency— 3-letter ISO code (e.g.IDR). Normalized to uppercase server-side.provider— the provider type string configured for your project (currentlymidtrans).customer— optional,name/emailboth optional.metadata— optional arbitrary JSON object, echoed back unchanged on every read of this payment.return_url— optional. Where Midtrans sends the customer's browser after they finish on the hosted checkout page — success, failure, or pending alike; the redirect itself doesn't distinguish outcomes. If omitted, the customer lands on a generic Midtrans page instead of back on your site. Never treat the redirect as proof of payment status — always confirm via the webhook (§9) orGET /payments/:id(§4).
Response — 201 Created
{
"id": "2f6a1e2e-2b7a-4b3e-8f2b-2a6a2e6a2e6a",
"reference": "ORDER-123",
"amount": "150000.00",
"currency": "IDR",
"status": "pending",
"provider_payment_id": "2f6a1e2e-2b7a-4b3e-8f2b-2a6a2e6a2e6a",
"payment_url": "https://app.sandbox.midtrans.com/snap/v4/redirection/2f6a1e2e-2b7a-4b3e-8f2b-2a6a2e6a2e6a",
"environment": "test",
"metadata": { "order_id": "ORDER-123" },
"created_at": "2026-01-01T02:00:00.000Z",
"updated_at": "2026-01-01T02:00:00.000Z"
}
id and provider_payment_id are the same value for a newly created payment — this is intentional (see §8). amount is always returned as a decimal string, never a float.
payment_url is the hosted checkout page (Midtrans Snap) your customer must visit to actually pay — see below. It is null for providers/flows that don't return a hosted checkout URL.
environment is "test" or "live", matching whichever API key created the payment. It's set once and never changes.
Completing the payment
Creating a payment does not charge the customer by itself — it opens a payment attempt with the provider and hands you back a checkout URL to send the customer to. The full flow:
- You call
POST /api/v1/payments(above). - The platform creates a Midtrans Snap transaction and gets back a
redirect_urlfrom Midtrans. - The platform returns that URL to you as
payment_url, alongside the payment instatus: "pending". - You redirect your customer's browser to
payment_url(or open it in a webview/new tab). - The customer pays on Midtrans's hosted page. If you sent
return_url, Midtrans then redirects the browser there — treat this only as a hint to show a "processing" screen, not as the actual result. - Midtrans calls the platform's webhook endpoint; the platform verifies it, updates the payment's status (
succeeded,failed,expired, …), and stores the event. - The platform delivers a signed webhook to your registered endpoint (§9) reflecting the new status.
- Your webhook handler updates your own order state — don't rely on the customer's browser redirect back to you as the source of truth, since they may close the tab before returning.
If you need to know the outcome before the customer returns to your site (e.g. to render a "processing" page), poll GET /api/v1/payments/:id (§4) or simply wait for the webhook — whichever fits your UX.
Idempotency
Send an Idempotency-Key header (any string unique to the logical operation — an order id is a good choice) with every create request:
- Same key + an equivalent request body → the original response is replayed, and the provider is not called again. Safe to retry on timeouts.
- Same key + a different request body →
409 Conflict(IDEMPOTENCY_CONFLICT). This means you reused a key for a logically different payment — use a new key. - No
Idempotency-Keyheader → every request creates a new payment attempt, even ifreferencerepeats. Always send one for anything triggered by a user action that might be retried (page refresh, double-click, network retry). - Idempotency keys are scoped per environment. Reusing the same key string in both your test and live integrations is safe — they never collide with each other.
04Get a payment
GET /api/v1/payments/:id
Authorization: Bearer sk_test_xxxxxxxxxxxxxxxxxxxxxxxx
Returns the same shape as the create response. 404 Not Found if the id doesn't exist or doesn't belong to your project.
05List payments
GET /api/v1/payments?status=succeeded&limit=50&cursor=<opaque-cursor>
Authorization: Bearer sk_test_xxxxxxxxxxxxxxxxxxxxxxxx
status— optional, one of the canonical statuses in §8.limit— optional, default 50, max 100.cursor— optional, pass thenext_cursorfrom the previous page to continue.
{
"data": [
{
"id": "...", "reference": "ORDER-123", "amount": "150000.00",
"currency": "IDR", "status": "succeeded", "provider_payment_id": "...",
"payment_url": "https://app.sandbox.midtrans.com/snap/v4/redirection/...",
"environment": "test",
"metadata": {}, "created_at": "...", "updated_at": "..."
}
],
"next_cursor": "3c9e2e2e-..."
}
next_cursor is null when there are no more pages.
06Cancel a payment
POST /api/v1/payments/:id/cancel
Authorization: Bearer sk_test_xxxxxxxxxxxxxxxxxxxxxxxx
Only works while the payment is still pending — matches Midtrans's own rule that a transaction can only be voided before it's paid. Returns the updated payment (same shape as §3's response) with status: "cancelled" on success.
404 Not Foundif the id doesn't exist or doesn't belong to your project.409 Conflict(PAYMENT_NOT_CANCELLABLE) if the payment is no longerpending(already paid, failed, expired, or already cancelled). A payment that's alreadysucceededneeds a refund, not a cancel — refunds aren't exposed through this API yet.
07Errors
Every error response has the same shape:
{
"error": {
"code": "validation_error",
"message": "amount must be a positive number",
"request_id": "req_2f6a1e2e-2b7a-4b3e-8f2b-2a6a2e6a2e6a"
}
}
Include request_id when contacting the platform operator about a specific failed request — it's what shows up in their logs.
| Status | error.code | Meaning |
|---|---|---|
| 400 | validation_error | Request body failed schema validation. |
| 400 | VALIDATION_ERROR | A domain rule was violated (e.g. malformed amount). |
| 401 | UNAUTHORIZED | Missing, invalid, or revoked API key. |
| 404 | NOT_FOUND | Payment (or other resource) not found for your project. |
| 409 | IDEMPOTENCY_CONFLICT | Idempotency-Key reused with a different request body. |
| 409 | PAYMENT_NOT_CANCELLABLE | Payment (§6) is no longer pending, so it can't be cancelled. |
| 429 | RATE_LIMITED | Too many requests; see Retry-After response header (seconds). |
| 500 | internal_error | Unexpected server-side failure. Safe to retry with backoff. |
08Payment statuses
Every provider's own status vocabulary is normalized into these before you ever see it:
- pending
- Created, awaiting payment.
- processing
- Payment in progress (e.g. bank transfer awaiting confirmation).
- succeeded
- Payment completed.
- failed
- Payment attempt failed.
- expired
- Payment window elapsed unpaid.
- cancelled
- Cancelled before completion.
- refunded
- Fully refunded.
- partially_refunded
- Partially refunded.
Only rely on these values — never branch your integration logic on a provider-specific status string.
09Receiving webhooks
Once a webhook endpoint is registered for your project (§1), the platform pushes normalized events to it as the underlying payment's status changes — including changes detected by the platform's own periodic reconciliation, not only ones driven by a provider's webhook.
Treat every event as possibly a duplicate — see deduplication below.
Request you'll receive
POST https://your-app.example.com/webhooks/payment
Content-Type: application/json
X-Webhook-Id: 8f2b2a6a-2e6a-4b3e-9f2b-2a6a2e6a2e6a
X-Webhook-Timestamp: 1767225600
X-Webhook-Signature: v1=6f2c1a9e...a3d4
{
"id": "8f2b2a6a-2e6a-4b3e-9f2b-2a6a2e6a2e6a",
"type": "payment.succeeded",
"created_at": "2026-01-01T02:05:00.000Z",
"data": {
"payment_id": "2f6a1e2e-2b7a-4b3e-8f2b-2a6a2e6a2e6a",
"reference": "ORDER-123",
"amount": 150000,
"currency": "IDR",
"status": "succeeded",
"environment": "test",
"provider": "midtrans"
}
}
type is payment.<canonical status> (e.g. payment.succeeded, payment.failed, payment.expired — see §8 for the full status list). data is null if the underlying payment can no longer be resolved (should not normally happen).
Verifying the signature
The signature is HMAC-SHA256(webhook_secret, "<timestamp>.<raw_request_body>"), hex-encoded, prefixed v1=. Verify against the raw request body bytes — not a re-serialized/parsed-then-stringified version, since re-serialization can change key order or whitespace and break the signature match.
const crypto = require('node:crypto')
function isValidWebhook(rawBody, headers, secret) {
const timestamp = headers['x-webhook-timestamp']
const signature = headers['x-webhook-signature']
// Reject stale requests — pick a tolerance appropriate for your clock skew.
const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp))
if (ageSeconds > 300) return false
const expected =
'v1=' +
crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex')
const expectedBuf = Buffer.from(expected, 'utf8')
const actualBuf = Buffer.from(signature ?? '', 'utf8')
return (
expectedBuf.length === actualBuf.length &&
crypto.timingSafeEqual(expectedBuf, actualBuf)
)
}
Reject the request (return 4xx, do not process) if the signature doesn't match or the timestamp is stale. Never process a webhook body before verifying it.
Responding
- Return any
2xxstatus once you've durably recorded the event (e.g. persisted to your own database) — you do not need to have finished all downstream processing first, just be sure you won't lose the event if you crash right after responding. - Anything else (timeout, non-2xx, connection failure) is treated as a delivery failure and retried on this schedule: immediate, +1 minute, +5 minutes, +15 minutes, +1 hour, +6 hours (6 attempts total). After that the delivery is marked
exhaustedand only resumes via manual retry from the dashboard. - Deduplicate on
X-Webhook-Id/id. Because delivery is at-least-once and retried on any non-2xx response, you may receive the same event more than once (including if you return 200 but your own process crashes before you record that fact). Use the event id as your own idempotency key.
10Quick reference
payment_url to actually pay