Integration guide
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.
The whole integration is three moving parts:
POST /v1/charge and gets back a redirect_url.150000) — never a float, never cents. Keep money as integers end to end.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.
| Field | Type | Required | Description |
|---|---|---|---|
Sandbox | base url | https://sandbox.pro-to-col.com — keys pk_sbx_… | |
Production | base url | https://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.
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/v1/chargeOpen a transaction. The body:
| Field | Type | Required | Description |
|---|---|---|---|
internal_ref | string | yes | Your own unique id for this order. You get it back on the webhook. Charging twice with the same ref returns the same transaction. |
gross_amount | integer | yes | The amount in whole rupiah. |
product_name | string | no | What was sold. Stored and shown; passthrough. |
customer | object | no | Optional first_name, last_name, email, phone. |
items | array | no | Optional line items (id, name, price, quantity). |
redirect | object | no | Where the payer lands after checkout: finish, pending, error. Overrides your dashboard defaults for this one charge. |
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" }
}'{
"order_id": "BGL-01JQD9F3K7Q0X2N8M4P6R5T3W1",
"snap_token": "66e4fa55-fdac-4ef9-91b5-3f8c0b2a91d1",
"redirect_url": "https://checkout.provider.example/pay/66e4fa55-..."
}// 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.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.
Protocol picks the return URL in this order, per outcome:
redirect object on this charge — finish, pending, error. Use it when the destination depends on the order.{
"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.
Protocol posts a signed POST to your webhook URL whenever a payment changes state. The body is JSON; three headers carry the signature:
| Field | Type | Required | Description |
|---|---|---|---|
x-protocol-signature | header | HMAC-SHA256 of `${timestamp}.${rawBody}`, hex. | |
x-protocol-timestamp | header | Unix seconds when we signed it. | |
x-protocol-event | header | The event name, e.g. payment.paid. |
{
"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"
}Three things, and the third is yours:
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
}200 quickly — do the heavy work off a stored copy.Every payload carries environment. Assert it matches where you are fulfilling, so a sandbox event can never fulfil a real order.
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).
| Status | Meaning |
|---|---|
pending | Charge created, awaiting payment. No money has moved. |
paid | Settled. The customer has paid; this is your fulfil signal. |
failed | The payment was denied or cancelled. |
expired | The customer did not pay in time. |
refunded | A previously paid transaction was refunded. |
challenge | Flagged by fraud detection; awaiting review before it settles. |
Webhooks are the source of truth, but you can also poll a transaction any time:
/v1/transactions/{order_id}curl https://pro-to-col.com/v1/transactions/BGL-01JQD9F3K7Q0X2N8M4P6R5T3W1 \
-H "Authorization: Bearer pk_live_YOUR_KEY"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.
https://pro-to-col.com and your pk_sbx_… key for the pk_live_… one.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.