Integration guide

Accept payments through Protocol

Protocol is the payment tunnel for the Soedja ecosystem. You open a charge, send your customer to pay, and receive a signed webhook when the money lands. One API key, one webhook secret, integer rupiah throughout.

Overview

The whole integration is three moving parts:

  • Create a charge — your server calls POST /v1/charge and gets back a redirect_url.
  • Redirect to pay — the customer pays on our provider’s hosted checkout. No card data ever touches your servers or ours.
  • Receive a webhook — Protocol posts a signed notification to your server the moment the payment settles, fails, or expires.
Every amount is an integer number of rupiah (e.g. 150000) — never a float, never cents. Keep money as integers end to end.

Environments

Sandbox and production are completely separate — separate URLs, separate credentials, separate ledgers. Test money and real money never mix. Your credentials carry their environment in the prefix, and a request to the wrong base URL with the wrong key is rejected before it touches anything.

FieldTypeRequiredDescription
Sandboxbase urlhttps://sandbox.pro-to-col.com — keys pk_sbx_…
Productionbase urlhttps://pro-to-col.com — keys pk_live_…

Build against sandbox first. It runs the exact same pipeline as production; the only difference is that no real money moves.

Authentication

Authenticate every request with your API key as a Bearer token. Issue and rotate it from your dashboard under Settings. Keep it server-side — anyone with your key can charge as you.

Authorization: Bearer pk_live_YOUR_KEY

Create a charge

POST/v1/charge

Open a transaction. The body:

FieldTypeRequiredDescription
internal_refstringyesYour own unique id for this order. You get it back on the webhook. Charging twice with the same ref returns the same transaction.
gross_amountintegeryesThe amount in whole rupiah.
product_namestringnoWhat was sold. Stored and shown; passthrough.
customerobjectnoOptional first_name, last_name, email, phone.
itemsarraynoOptional line items (id, name, price, quantity).
redirectobjectnoWhere the payer lands after checkout: finish, pending, error. Overrides your dashboard defaults for this one charge.

Request

curl -X POST https://pro-to-col.com/v1/charge \
  -H "Authorization: Bearer pk_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "internal_ref": "invoice-1001",
    "product_name": "Weekend Pass",
    "gross_amount": 150000,
    "customer": { "first_name": "Agie", "email": "agie@example.com" }
  }'

Response

{
  "order_id": "BGL-01JQD9F3K7Q0X2N8M4P6R5T3W1",
  "snap_token": "66e4fa55-fdac-4ef9-91b5-3f8c0b2a91d1",
  "redirect_url": "https://checkout.provider.example/pay/66e4fa55-..."
}

From Node

// Keep your API key on the server — never ship it to the browser.
const res = await fetch("https://pro-to-col.com/v1/charge", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.PROTOCOL_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    internal_ref: "invoice-1001",       // your own unique id for this order
    product_name: "Weekend Pass",
    gross_amount: 150000,               // integer rupiah, never a float
    customer: { first_name: "Agie" },
  }),
});

const { order_id, redirect_url } = await res.json();
// Send the customer to redirect_url to pay.

Redirect to pay

Send the customer to the redirect_url from the charge response — it is the provider’s hosted checkout, where they choose a method and pay. When they finish, the provider returns them to your return page, but do not fulfil on the redirect: the redirect is a UI event and can be spoofed or dropped. Fulfil on the webhook, which is signed and authoritative.

Where your customer lands afterwards

Protocol picks the return URL in this order, per outcome:

  • The redirect object on this chargefinish, pending, error. Use it when the destination depends on the order.
  • Your saved defaults, set in the dashboard under Settings → Payment redirects. Set these once and every charge uses them.
  • Protocol’s own return page, if you have set neither. Your customer sees a neutral “payment received / pending / failed” screen with the amount and your name — never another company’s site.
{
  "internal_ref": "invoice-1001",
  "gross_amount": 150000,
  "redirect": {
    "finish":  "https://your-app.com/orders/1001/thanks",
    "pending": "https://your-app.com/orders/1001/pending",
    "error":   "https://your-app.com/orders/1001/failed"
  }
}

Whichever URL is used, the provider appends order_id, status_code and transaction_status to it, so your page can read the order from the query string.

Those query parameters are not proof of payment — anyone can type them into a URL. Treat the return page as a place to say “thanks”, and read the real state from your own records, which the webhook updated.

Receive webhooks

Protocol posts a signed POST to your webhook URL whenever a payment changes state. The body is JSON; three headers carry the signature:

FieldTypeRequiredDescription
x-protocol-signatureheaderHMAC-SHA256 of `${timestamp}.${rawBody}`, hex.
x-protocol-timestampheaderUnix seconds when we signed it.
x-protocol-eventheaderThe event name, e.g. payment.paid.

Payload

{
  "event": "payment.paid",
  "order_id": "BGL-01JQD9F3K7Q0X2N8M4P6R5T3W1",
  "internal_ref": "invoice-1001",
  "status": "paid",
  "gross_amount": 150000,
  "payment_type": "gopay",
  "paid_at": "2026-07-16T03:12:44.000Z",
  "metadata": {},
  "environment": "production"
}

Verify it

Three things, and the third is yours:

  • Recompute the HMAC over the raw body and compare in constant time.
  • Reject a stale timestamp — this stops a captured request being replayed.
  • Fulfil idempotently keyed on order_id. Protocol may legitimately re-deliver the same event, so fulfilling twice must be a no-op.
import crypto from "node:crypto";

// Mount this on the URL you registered as your webhook.
export function handleProtocolWebhook(req, res, secret) {
  const raw = req.rawBody;                        // EXACT bytes, before JSON.parse
  const sig = req.headers["x-protocol-signature"];
  const ts  = req.headers["x-protocol-timestamp"];

  // 1. Recompute the HMAC and compare in constant time.
  const expected = crypto.createHmac("sha256", secret)
    .update(`${ts}.${raw}`)
    .digest("hex");
  const ok = sig.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
  if (!ok) return res.status(401).end();

  // 2. Reject a stale timestamp (replay protection).
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.status(401).end();

  // 3. Fulfil idempotently on order_id — Protocol may re-deliver the same event.
  const event = JSON.parse(raw);
  fulfilOnce(event.order_id, event);

  return res.status(200).end();                   // always 200, quickly
}
Read the raw request body before parsing it. Re-serialising a parsed object reorders keys and the signature will not match. Always respond 200 quickly — do the heavy work off a stored copy.

Assert on the environment

Every payload carries environment. Assert it matches where you are fulfilling, so a sandbox event can never fulfil a real order.

Payment statuses

A transaction moves forward only — it never goes backwards. The events you receive map one-to-one to these statuses (payment.paid, payment.failed, and so on).

StatusMeaning
pendingCharge created, awaiting payment. No money has moved.
paidSettled. The customer has paid; this is your fulfil signal.
failedThe payment was denied or cancelled.
expiredThe customer did not pay in time.
refundedA previously paid transaction was refunded.
challengeFlagged by fraud detection; awaiting review before it settles.

Check a status directly

Webhooks are the source of truth, but you can also poll a transaction any time:

GET/v1/transactions/{order_id}
curl https://pro-to-col.com/v1/transactions/BGL-01JQD9F3K7Q0X2N8M4P6R5T3W1 \
  -H "Authorization: Bearer pk_live_YOUR_KEY"

Testing

In sandbox you can drive a payment to any outcome without paying. Protocol builds a genuinely-signed provider notification and runs it through the real pipeline, so the webhook your server receives is a real one — the same code path a live payment takes. Use the simulator in your dashboard, or the public sandbox simulator.

Going live

  • Get your production account approved by the Protocol team.
  • Swap the base URL to https://pro-to-col.com and your pk_sbx_… key for the pk_live_… one.
  • Point your production webhook at a live, verifying endpoint.
  • Confirm you fulfil on the webhook, verify the signature, reject stale timestamps, and assert on environment.

Ready to build? Create an account — sandbox is instant, and the dashboard has a copy-paste brief for your AI coding tool with everything filled in for you.