API documentation
Ostervo has one core object — the checkout session. You create one, send your customer to it, and wait for a webhook. Everything else on this page is detail around that loop.
Base URL
http://localhost:3000/api/v1Quickstart
Three steps from nothing to a working payment. Replace the key with your own from the dashboard.
Confirm your credentials work
curl http://localhost:3000/api/v1/account \
-H "Authorization: Bearer sk_test_…"A 200 with your business name means you are ready. A 401 means the key is wrong or revoked.
Create a checkout session
curl -X POST http://localhost:3000/api/v1/sessions \
-H "Authorization: Bearer sk_test_…" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: order-1042-attempt-1" \
-d '{
"amount": 450000,
"currency": "PKR",
"reference": "ORDER-1042",
"description": "2× cotton kurta",
"customer_email": "buyer@example.com",
"success_url": "https://yourstore.com/thanks",
"cancel_url": "https://yourstore.com/cart"
}'Redirect the buyer to the checkout_url in the response.
Receive the result
Register a webhook endpoint in the dashboard. When you approve the payment, we POST a signed payment.approved event to it. Treat that — and only that — as proof the order is paid.
Authentication
Send your secret key as a bearer token on every request. Each key belongs to one mode; a sk_test_ key can only ever see test data, and a sk_live_ key only live data.
Authorization: Bearer sk_live_…If your host strips Authorization headers
Authorization header before PHP sees it. If that is happening, send X-Ostervo-Secret-Key instead — it is accepted as an equivalent fallback.Secret keys are server-side only
pk_…) are safe to expose but cannot call the endpoints on this page.Amounts and currency
Every amount is an integer in the currency's minor unit. No decimals, ever. This removes an entire class of rounding bug from the system.
| You mean | You send | Currency |
|---|---|---|
| Rs 4,500.00 | 450000 | PKR |
| Rs 99.50 | 9950 | PKR |
| $12.00 | 1200 | USD |
| £1,250.75 | 125075 | GBP |
If currency is omitted it defaults to your account currency.
Idempotency
Send an Idempotency-Key header on every session create. If the same key arrives again with the same body, you get the original response back instead of a second session.
Idempotency-Key: order-1042-attempt-1Keys are scoped to your account and live for 24 hours. Reusing a key with a different body returns 409 idempotency_conflict — that almost always means a bug on your side worth knowing about.
There is a second safety net
reference is unique per account. Even without an idempotency key, a retry with the same order reference is rejected with 409 duplicate_reference rather than creating a duplicate payment.Checkout sessions
/api/v1/sessionsParameters
| Field | Type | Notes |
|---|---|---|
| amount | integer, required | Minor units. Must be positive. |
| reference | string, required | Your order id. Unique per account. |
| currency | string | ISO code. Defaults to your account currency. |
| description | string | Shown to the buyer at checkout. |
| customer_name | string | Optional. |
| customer_email | string | Optional. Used for support follow-up. |
| customer_phone | string | Optional. |
| success_url | url | Where "Return to store" goes after approval. |
| cancel_url | url | Where the buyer goes if they abandon. |
| expires_in_minutes | integer | 5–20160. Defaults to the platform setting. |
| metadata | object | Up to 20 string keys, round-tripped untouched. |
Response
{
"id": "ost_cs_live_9fA3kQ2mXpLr7TnBvCyD",
"object": "checkout_session",
"mode": "LIVE",
"status": "REQUIRES_METHOD",
"amount": 450000,
"currency": "PKR",
"reference": "ORDER-1042",
"checkout_url": "http://localhost:3000/pay/ost_cs_live_9fA3kQ2mXpLr7TnBvCyD",
"customer": { "name": null, "email": "buyer@example.com", "phone": null },
"selected_payment_method": null,
"proof": null,
"fee_amount": 0,
"net_amount": 0,
"metadata": {},
"expires_at": "2026-07-28T15:30:00.000Z",
"approved_at": null,
"created_at": "2026-07-28T14:30:00.000Z"
}/api/v1/sessions/:idRetrieve one session. Expiry is evaluated on read, so this always reflects the true current state. Use it as a fallback if you cannot receive webhooks — poll every 30–60 seconds, not faster.
/api/v1/sessionsList sessions, newest first. Supports limit (1–100), status, reference, and starting_after for cursor pagination.
/api/v1/sessions/:id/cancelClose a session the buyer abandoned. Approved sessions cannot be cancelled — money has already moved, and undoing that is a refund you handle yourself.
Session statuses
A session moves through these states. Only APPROVED means you have been paid.
| Status | Meaning | Fulfil the order? |
|---|---|---|
| REQUIRES_METHOD | Created; buyer has not chosen an account yet. | No |
| AWAITING_PROOF | Buyer picked an account and is transferring. | No |
| UNDER_REVIEW | Receipt uploaded, sitting in your review queue. | No |
| APPROVED | You confirmed the funds arrived. | Yes |
| REJECTED | You rejected the receipt. Buyer may retry. | No |
| EXPIRED | Deadline passed without approval. | No |
| CANCELLED | Cancelled by you or the buyer. | No |
UNDER_REVIEW is not a payment
Payment methods
/api/v1/payment-methodsYour active receiving accounts, with the details buyers need. Use this if you want to render the account information inside your own checkout rather than redirecting to the hosted page.
{
"object": "list",
"data": [
{
"id": "clx8k2m…",
"type": "EASYPAISA",
"type_name": "Easypaisa",
"label": "Easypaisa — 0345 1234567",
"account_title": "Ayesha Siddiqui",
"account_number": "0345 1234567",
"iban": null,
"qr_code_url": null,
"instructions": "Open Easypaisa → Send Money → Mobile Account.",
"brand_color": "#22C55E",
"min_amount": null,
"max_amount": null
}
]
}Account
/api/v1/accountYour account standing, plan limits, and collected totals for the current mode. The cheapest possible integration smoke test.
Webhooks
Register an endpoint in the dashboard and we POST JSON to it as things happen. Each request carries these headers:
| Header | Purpose |
|---|---|
| Ostervo-Signature | t=<unix>,v1=<hex hmac-sha256> |
| Ostervo-Event-Id | Unique event id — deduplicate on this. |
| Ostervo-Event-Type | e.g. payment.approved |
Events
| Event | Meaning |
|---|---|
| session.created | A checkout session was created via the API. |
| session.expired | A session passed its expiry without being approved. |
| session.cancelled | A session was cancelled by the integrator or buyer. |
| proof.submitted | The buyer uploaded a payment receipt for review. |
| payment.approved | The merchant confirmed funds were received. Fulfil the order. |
| payment.rejected | The merchant rejected the receipt. Do not fulfil. |
Payload
{
"id": "evt_7Kd2mQpXr9…",
"object": "event",
"type": "payment.approved",
"mode": "LIVE",
"created": 1785312000,
"data": {
"session": { /* full checkout_session object */ },
"review_note": "Matched to statement line 14:32"
}
}Verifying the signature
Do this before you trust anything in the body. Without it, anyone who learns your endpoint URL can post a fake payment.approved and get free goods.
Node.js
const crypto = require('crypto');
function verifyOstervo(rawBody, signatureHeader, secret) {
const parts = Object.fromEntries(
signatureHeader.split(',').map((p) => p.split('='))
);
// Reject anything outside a 5-minute window, to stop replays.
const age = Math.abs(Date.now() / 1000 - Number(parts.t));
if (!Number.isFinite(age) || age > 300) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(`${parts.t}.${rawBody}`)
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1 || '');
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
// Express — note express.raw, NOT express.json
app.post('/webhooks/ostervo',
express.raw({ type: 'application/json' }),
(req, res) => {
const ok = verifyOstervo(
req.body.toString('utf8'),
req.get('Ostervo-Signature'),
process.env.OSTERVO_WEBHOOK_SECRET
);
if (!ok) return res.status(400).send('bad signature');
const event = JSON.parse(req.body.toString('utf8'));
res.sendStatus(200); // acknowledge FIRST
if (event.type === 'payment.approved') {
queue.add('fulfil', { reference: event.data.session.reference });
}
}
);PHP
<?php
function ostervo_verify(string $rawBody, string $header, string $secret): bool {
$parts = [];
foreach (explode(',', $header) as $piece) {
[$k, $v] = array_pad(explode('=', $piece, 2), 2, '');
$parts[trim($k)] = trim($v);
}
if (!isset($parts['t'], $parts['v1'])) {
return false;
}
if (abs(time() - (int) $parts['t']) > 300) {
return false; // replay window
}
$expected = hash_hmac('sha256', $parts['t'] . '.' . $rawBody, $secret);
return hash_equals($expected, $parts['v1']);
}
$raw = file_get_contents('php://input');
$header = $_SERVER['HTTP_OSTERVO_SIGNATURE'] ?? '';
if (!ostervo_verify($raw, $header, getenv('OSTERVO_WEBHOOK_SECRET'))) {
http_response_code(400);
exit('bad signature');
}
$event = json_decode($raw, true);Python
import hmac, hashlib, time
def verify_ostervo(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(
p.split("=", 1) for p in header.split(",") if "=" in p
)
try:
timestamp = int(parts["t"])
except (KeyError, ValueError):
return False
if abs(time.time() - timestamp) > 300:
return False
expected = hmac.new(
secret.encode(),
f"{timestamp}.".encode() + raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))Use the raw bytes
Retries
Any non-2xx response, or a timeout past 10 seconds, is retried with backoff: 10s, 1m, 5m, 30m, 2h, 6h. After that the delivery is marked exhausted and you can replay it by hand from the dashboard. Because retries can follow a success your server was too slow to report, deduplicate on Ostervo-Event-Id.
Errors
Every error uses the same envelope. Branch on code — it is stable. message is for humans and may be reworded.
{
"error": {
"code": "duplicate_reference",
"message": "A session already exists for reference \"ORDER-1042\".",
"param": "reference"
}
}| HTTP | Code | What to do |
|---|---|---|
| 400 | invalid_request | Fix the payload. `details` lists every bad field. |
| 401 | unauthorized | Key is missing, malformed, or revoked. |
| 403 | merchant_inactive | Account is pending or suspended. Contact support. |
| 404 | not_found | No such session for this account. |
| 409 | duplicate_reference | Retrieve the existing session instead. |
| 409 | idempotency_conflict | Key reused with a different body. Use a new key. |
| 422 | no_payment_methods | Add a receiving account in the dashboard. |
| 429 | rate_limited | Back off. Honour the Retry-After header. |
| 500 | server_error | Safe to retry with the same Idempotency-Key. |
Rate limits: 120 requests/minute per key for reads, 60/minute for session creation. Every response carries X-RateLimit-Remaining.
WordPress / WooCommerce
There is no SDK to install — the API is plain HTTP. This is a complete, working WooCommerce gateway in about sixty lines.
<?php
// 1. Create the session when the customer places the order.
add_action('woocommerce_checkout_order_processed', function ($order_id) {
$order = wc_get_order($order_id);
if ($order->get_payment_method() !== 'ostervo') {
return;
}
$response = wp_remote_post('http://localhost:3000/api/v1/sessions', [
'timeout' => 20,
'headers' => [
'Authorization' => 'Bearer ' . OSTERVO_SECRET_KEY,
'Content-Type' => 'application/json',
// Deterministic key: a retried checkout reuses the session.
'Idempotency-Key' => 'wc-order-' . $order_id,
],
'body' => wp_json_encode([
'amount' => (int) round($order->get_total() * 100),
'currency' => $order->get_currency(),
'reference' => (string) $order->get_order_number(),
'customer_name' => $order->get_billing_first_name() . ' '
. $order->get_billing_last_name(),
'customer_email' => $order->get_billing_email(),
'success_url' => $order->get_checkout_order_received_url(),
'cancel_url' => wc_get_checkout_url(),
'metadata' => ['wc_order_id' => $order_id],
]),
]);
if (is_wp_error($response)) {
throw new Exception('Could not reach the payment gateway.');
}
$session = json_decode(wp_remote_retrieve_body($response), true);
if (empty($session['checkout_url'])) {
throw new Exception($session['error']['message'] ?? 'Gateway error.');
}
$order->update_meta_data('_ostervo_session_id', $session['id']);
$order->update_status('pending', 'Awaiting payment via Ostervo.');
$order->save();
// Send the buyer to the hosted checkout.
wp_safe_redirect($session['checkout_url']);
exit;
});
// 2. Receive the result.
add_action('rest_api_init', function () {
register_rest_route('ostervo/v1', '/webhook', [
'methods' => 'POST',
'permission_callback' => '__return_true', // signature IS the auth
'callback' => function (WP_REST_Request $request) {
$raw = $request->get_body();
if (!ostervo_verify($raw, $request->get_header('ostervo-signature'),
OSTERVO_WEBHOOK_SECRET)) {
return new WP_REST_Response(['error' => 'bad signature'], 400);
}
$event = json_decode($raw, true);
if ($event['type'] === 'payment.approved') {
$order_id = $event['data']['session']['metadata']['wc_order_id'] ?? null;
$order = $order_id ? wc_get_order($order_id) : null;
// payment_complete() is idempotent, so a duplicate delivery
// cannot double-process the order.
if ($order && !$order->is_paid()) {
$order->payment_complete($event['data']['session']['id']);
}
}
return new WP_REST_Response(['received' => true], 200);
},
]);
});Point the webhook here
https://yourstore.com/wp-json/ostervo/v1/webhook in the dashboard, and store the signing secret in wp-config.php — never in the database or a plugin setting that gets exported.Going live
Test and live mode share the same endpoints and the same code path. The only difference is which key you send.
- Test sessions are labelled at checkout so nobody transfers real money by mistake, and they never appear in live reporting.
- A test key works before your account is approved, so you can finish the integration while verification is in progress.
- Switching to live means swapping the key. Nothing else in your code changes.
Before you take real money
- Verify webhook signatures — and confirm you reject a tampered payload.
- Handle payment.rejected, not just payment.approved.
- Deduplicate on Ostervo-Event-Id so a retry cannot ship twice.
- Send an Idempotency-Key on every create call.
- Never treat UNDER_REVIEW as paid.
- Check the money in your own account before approving.
Session statuses in this document: REQUIRES_METHOD (Awaiting method), AWAITING_PROOF (Awaiting payment), UNDER_REVIEW (Under review), APPROVED (Approved), REJECTED (Rejected), EXPIRED (Expired), CANCELLED (Cancelled).