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:

  1. Your project to be created.
  2. An API key issued for your project, as either test or live (prefixed sk_test_... / sk_live_...). This is fully enforced: a test key always uses Midtrans sandbox credentials and every payment it creates is tagged environment: "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.
  3. 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/live environment: it only receives deliveries for payments created in that same environment. Register both a test and a live endpoint up front — whichever matches a given payment is picked automatically, no manual switching later.
Save it now

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:

HTTP
Authorization: Bearer sk_live_xxxxxxxxxxxxxxxxxxxxxxxx

Requests without a valid, non-revoked key receive 401 Unauthorized.

03Create a payment

HTTP
POST /api/v1/payments
Content-Type: application/json
Authorization: Bearer sk_test_xxxxxxxxxxxxxxxxxxxxxxxx
Idempotency-Key: order-123
Request body
{
  "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. 150000 or "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 (currently midtrans).
  • customer — optional, name/email both 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) or GET /payments/:id (§4).

Response — 201 Created

JSON
{
  "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:

  1. You call POST /api/v1/payments (above).
  2. The platform creates a Midtrans Snap transaction and gets back a redirect_url from Midtrans.
  3. The platform returns that URL to you as payment_url, alongside the payment in status: "pending".
  4. You redirect your customer's browser to payment_url (or open it in a webview/new tab).
  5. 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.
  6. Midtrans calls the platform's webhook endpoint; the platform verifies it, updates the payment's status (succeeded, failed, expired, …), and stores the event.
  7. The platform delivers a signed webhook to your registered endpoint (§9) reflecting the new status.
  8. 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-Key header → every request creates a new payment attempt, even if reference repeats. 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

HTTP
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

HTTP
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 the next_cursor from the previous page to continue.
JSON
{
  "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

HTTP
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 Found if the id doesn't exist or doesn't belong to your project.
  • 409 Conflict (PAYMENT_NOT_CANCELLABLE) if the payment is no longer pending (already paid, failed, expired, or already cancelled). A payment that's already succeeded needs a refund, not a cancel — refunds aren't exposed through this API yet.

07Errors

Every error response has the same shape:

JSON
{
  "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.

Statuserror.codeMeaning
400validation_errorRequest body failed schema validation.
400VALIDATION_ERRORA domain rule was violated (e.g. malformed amount).
401UNAUTHORIZEDMissing, invalid, or revoked API key.
404NOT_FOUNDPayment (or other resource) not found for your project.
409IDEMPOTENCY_CONFLICTIdempotency-Key reused with a different request body.
409PAYMENT_NOT_CANCELLABLEPayment (§6) is no longer pending, so it can't be cancelled.
429RATE_LIMITEDToo many requests; see Retry-After response header (seconds).
500internal_errorUnexpected 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.

At-least-once delivery

Treat every event as possibly a duplicate — see deduplication below.

Request you'll receive

HTTP
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
JSON
{
  "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.

Node.js
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)
  )
}
Verify before processing

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 2xx status 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 exhausted and 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

AuthAuthorization: Bearer <api_key>
Create paymentPOST /api/v1/payments (Idempotency-Key required for anything retryable)
→ redirect customer to response's payment_url to actually pay
Get paymentGET /api/v1/payments/:id
List paymentsGET /api/v1/payments?status=&limit=&cursor=
Cancel paymentPOST /api/v1/payments/:id/cancel (only while status is "pending")
Your webhookPOST <your registered URL> (verify X-Webhook-Signature before trusting the body)