API Reference
CartHub is a headless payment hub. Your app creates an invoice through this API, the customer pays via Tripay, and CartHub notifies your app with a signed webhook. Customers never need to see CartHub.
The integration flow: obtain an API key and secret, sign every request, create an
invoice with a stable externalId and an Idempotency-Key,
redirect the customer to paymentUrl, then receive a webhook (or poll the
invoice) when the payment settles.
Base URL
All requests are made to the production base URL. Bodies are JSON.
https://api.cart.kirimlead.com - All request and response bodies are JSON (
Content-Type: application/json). - Money is always integer cents —
amountCents: 1499000= Rp 14.990,00. - Timestamps are ISO 8601 UTC strings.
Prerequisites
Before calling the API you need an application and an API key. An application represents the source app that creates invoices (your SaaS, landing page, etc.); the key and secret are what your backend uses to authenticate requests.
- Sign in to the CartHub dashboard with an admin account.
-
Open Applications → New application and fill in
the name, code, webhook URL, webhook secret (a string of 16+ characters
you choose), default provider (
tripay), and enabled providers. -
In the application, click Create API key. You will be shown two
values once:
- Key — looks like
ch_live_AbCd1234…. Sent in theX-CartHub-Keyheader on every request. - Secret — looks like
ch_secret_…. Used to compute the HMAC signature. Store it securely; it is never shown again.
- Key — looks like
-
Copy both values into your backend environment variables (for example
CARTHUB_API_KEYandCARTHUB_API_SECRET). Also store the webhook secret you chose in step 2 (for exampleCARTHUB_WEBHOOK_SECRET) — your webhook handler needs it to verify theX-Webhook-Signatureheader.
Rotation: one application can hold multiple API keys. To rotate, create a new key, roll it out to your backend, then revoke the old one — no downtime.
Authentication
The public invoice API (/api/v1/invoices/*) requires an API key plus an
HMAC request signature. Every request carries three headers:
| Header | Description |
|---|---|
X-CartHub-Key | Plaintext API key, e.g. ch_live_AbCd1234…. The first 12 chars are the lookup prefix. |
X-CartHub-Timestamp | Current Unix time in seconds. Stale or far-future values are rejected with 401. |
X-CartHub-Signature | Lowercase hex HMAC-SHA256 of the signing string (below). |
Signing string
Build a newline-separated string, then HMAC-SHA256 it with your API secret:
METHOD\nPATH_WITH_QUERY\nTIMESTAMP\nSHA256_HEX(BODY) METHOD— uppercase HTTP method.PATH_WITH_QUERY— path including query string, exactly as sent.TIMESTAMP— same value as theX-CartHub-Timestampheader.SHA256_HEX(BODY)— lowercase hex SHA-256 of the raw body. For GET/DELETE (empty body) this is the SHA-256 of an empty string:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.
The API secret is shown once at key creation and never again.
Scopes
API keys carry scopes. A missing scope returns 403 FORBIDDEN.
| Scope | Grants |
|---|---|
invoices:read | Read invoices (GET endpoints). |
invoices:write | Create or cancel invoices. |
Signing helper
import { createHash, createHmac } from "node:crypto";
function sign(opts: {
method: string;
pathWithQuery: string;
body: string;
apiSecret: string;
}) {
const timestamp = Math.floor(Date.now() / 1000).toString();
const bodyHash = createHash("sha256").update(opts.body).digest("hex");
const signingString = [opts.method, opts.pathWithQuery, timestamp, bodyHash].join("\n");
const signature = createHmac("sha256", opts.apiSecret)
.update(signingString)
.digest("hex");
return { timestamp, signature };
} <?php
function sign(string $method, string $pathWithQuery, string $body, string $apiSecret): array {
$timestamp = (string) time();
$bodyHash = hash("sha256", $body);
$signingString = implode("\n", [$method, $pathWithQuery, $timestamp, $bodyHash]);
$signature = hash_hmac("sha256", $signingString, $apiSecret);
return ["timestamp" => $timestamp, "signature" => $signature];
} import hashlib
import hmac
import time
def sign(method: str, path_with_query: str, body: str, api_secret: str) -> dict:
timestamp = str(int(time.time()))
body_hash = hashlib.sha256(body.encode()).hexdigest()
signing_string = "\n".join([method, path_with_query, timestamp, body_hash])
signature = hmac.new(api_secret.encode(), signing_string.encode(), hashlib.sha256).hexdigest()
return {"timestamp": timestamp, "signature": signature} package carthub
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strconv"
"strings"
"time"
)
func Sign(method, pathWithQuery, body, apiSecret string) (timestamp, signature string) {
timestamp = strconv.FormatInt(time.Now().Unix(), 10)
sum := sha256.Sum256([]byte(body))
bodyHash := hex.EncodeToString(sum[:])
signingString := strings.Join([]string{method, pathWithQuery, timestamp, bodyHash}, "\n")
mac := hmac.New(sha256.New, []byte(apiSecret))
mac.Write([]byte(signingString))
signature = hex.EncodeToString(mac.Sum(nil))
return timestamp, signature
} Errors
All errors share one shape:
{
"error": {
"code": "UNAUTHORIZED",
"message": "Invalid signature"
}
} | Status | Code | Meaning |
|---|---|---|
| 400 | BAD_REQUEST | Validation failed. |
| 401 | UNAUTHORIZED | Missing/invalid headers, key, signature, or timestamp. |
| 403 | FORBIDDEN | App inactive or missing scope. |
| 404 | NOT_FOUND | Resource not found. |
| 409 | CONFLICT | Invalid state transition, e.g. receipt before paid. |
| 429 | RATE_LIMITED | Too many requests — see Rate limits. |
| 503 | EXTERNAL_API_ERROR | Upstream/storage not configured. |
Quickstart
Create your first invoice and redirect the customer to the payment page:
import { createHash, createHmac } from "node:crypto";
const BASE = "https://api.cart.kirimlead.com";
const path = "/api/v1/invoices";
const body = JSON.stringify({
externalId: "order-123",
customerName: "Budi Santoso",
customerEmail: "budi@example.com",
itemName: "Pro Plan (1 tahun)",
amountCents: 1_499_000,
currency: "IDR",
paymentChannel: "QRIS",
});
const { timestamp, signature } = sign({
method: "POST",
pathWithQuery: path,
body,
apiSecret: process.env.CARTHUB_SECRET!,
});
const res = await fetch(BASE + path, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CartHub-Key": process.env.CARTHUB_KEY!,
"X-CartHub-Timestamp": timestamp,
"X-CartHub-Signature": signature,
"Idempotency-Key": "order-123",
},
body,
});
const invoice = await res.json();
console.log(invoice.paymentUrl); <?php
// assumes sign() from the Authentication section
$base = "https://api.cart.kirimlead.com";
$path = "/api/v1/invoices";
$body = json_encode([
"externalId" => "order-123",
"customerName" => "Budi Santoso",
"customerEmail" => "budi@example.com",
"itemName" => "Pro Plan (1 tahun)",
"amountCents" => 1499000,
"currency" => "IDR",
"paymentChannel" => "QRIS",
]);
["timestamp" => $ts, "signature" => $sig] =
sign("POST", $path, $body, getenv("CARTHUB_SECRET"));
$ch = curl_init($base . $path);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $body,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
"Content-Type: application/json",
"X-CartHub-Key: " . getenv("CARTHUB_KEY"),
"X-CartHub-Timestamp: " . $ts,
"X-CartHub-Signature: " . $sig,
"Idempotency-Key: order-123",
],
]);
$invoice = json_decode(curl_exec($ch), true);
echo $invoice["paymentUrl"]; import os
import requests
# assumes sign() from the Authentication section
base = "https://api.cart.kirimlead.com"
path = "/api/v1/invoices"
body = json.dumps({
"externalId": "order-123",
"customerName": "Budi Santoso",
"customerEmail": "budi@example.com",
"itemName": "Pro Plan (1 tahun)",
"amountCents": 1499000,
"currency": "IDR",
"paymentChannel": "QRIS",
})
sig = sign("POST", path, body, os.environ["CARTHUB_SECRET"])
res = requests.post(
base + path,
data=body,
headers={
"Content-Type": "application/json",
"X-CartHub-Key": os.environ["CARTHUB_KEY"],
"X-CartHub-Timestamp": sig["timestamp"],
"X-CartHub-Signature": sig["signature"],
"Idempotency-Key": "order-123",
},
)
print(res.json()["paymentUrl"]) package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
)
func main() {
base := "https://api.cart.kirimlead.com"
path := "/api/v1/invoices"
body, _ := json.Marshal(map[string]any{
"externalId": "order-123",
"customerName": "Budi Santoso",
"customerEmail": "budi@example.com",
"itemName": "Pro Plan (1 tahun)",
"amountCents": 1499000,
"currency": "IDR",
"paymentChannel": "QRIS",
})
ts, sig := Sign("POST", path, string(body), os.Getenv("CARTHUB_SECRET"))
req, _ := http.NewRequest("POST", base+path, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-CartHub-Key", os.Getenv("CARTHUB_KEY"))
req.Header.Set("X-CartHub-Timestamp", ts)
req.Header.Set("X-CartHub-Signature", sig)
req.Header.Set("Idempotency-Key", "order-123")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
var invoice map[string]any
json.NewDecoder(res.Body).Decode(&invoice)
fmt.Println(invoice["paymentUrl"])
} Rate limits
Public API requests (/api/v1/*) are throttled to 120 requests per 60 seconds
per API key, identified by the X-CartHub-Key header. When the limit is exceeded the API
responds with 429 and the error code RATE_LIMITED.
| Scope | Limit | Window | Keyed by |
|---|---|---|---|
/api/v1/* | 120 requests | 60 seconds | X-CartHub-Key |
The bucket resets fully 60 seconds after the first request in the window. Admin endpoints
(/api/admin/*) and the Tripay callback (/api/v1/callbacks/tripay) are not rate-limited
by this middleware.
No Retry-After header is sent on 429 responses. Implement a fixed 60-second backoff
in your client and reuse the Idempotency-Key header (see below) so retries never create duplicate
invoices.
Idempotency
POST /api/v1/invoices accepts an optional Idempotency-Key header so that retries
after a network blip never create a duplicate invoice. Send any unique string (a UUID works well).
| Scenario | Result |
|---|---|
| Same key, identical body | 200 — original response replayed, no new invoice |
| Same key, different body | 409 CONFLICT — Idempotency-Key was already used with a different request body |
| No key | 201 — normal creation, no replay protection |
Always send an Idempotency-Key when creating invoices programmatically. It is the safe way to
retry after a timeout or 429 without risking double charges.
Sandbox & testing
CartHub routes payments through Tripay,
which offers a sandbox environment for testing without real money. Toggle it in
Settings → Provider → Tripay by setting Production mode off (the
isProduction flag). In sandbox mode CartHub calls https://tripay.co.id/api-sandbox
instead of the live API.
Sandbox transactions are real Tripay transactions with a DEV- prefixed reference (for example
DEV-T7608386003WEEA4) and return a working paymentUrl / QR code. No money moves.
Not every payment channel is enabled on every sandbox merchant. If a channel returns
"Payment channel is not enabled", switch to another (for example QRIS2,
BCAVA, or DANA) in your createInvoice request.
API versioning
The API is versioned via the URL path: /api/v1/.... The current and only version is
v1. Breaking changes (removed fields, changed semantics, altered status codes) will ship
under a new path segment such as /api/v2/... so existing integrations keep working unchanged.
Within a major version, CartHub only adds non-breaking changes: new optional request fields, new response fields, new endpoints, and new webhook events. Always ignore unknown fields in responses so your integration stays forward-compatible.
There is no version-selection header. Pin your integration to v1 by using the
/api/v1/ path prefix — it will remain stable even after a future v2 ships.
POST /api/v1/invoices
invoices:write Creates an invoice and a Tripay transaction. Returns 201, or 200 on an idempotent replay.
Optional header Idempotency-Key: replaying the same key with an identical
body returns the original result with 200 instead of creating a duplicate.
Body parameters
| Field | Type | Required | Description |
|---|---|---|---|
externalId | string | Yes | Your order id (1–200 chars). Use it for idempotency and lookups. |
customerName | string | Yes | 1–200 chars. |
customerEmail | string | Yes | Valid email, max 254. |
customerPhone | string | No | Matches ^\+?[0-9]{8,16}$. |
itemName | string | Yes | 1–200 chars. |
amountCents | integer | Yes | Positive integer cents. |
currency | enum | No | IDR or USD. Defaults to IDR. |
paymentChannel | string | No | Tripay method, e.g. QRIS, BRIVA, BCAVA. Falls back to the server default. |
items | array | No | Line items: name, quantity (≥1), unitPriceCents (≥0), optional description, totalCents. |
successUrl | string | No | Where to send the customer after a successful payment. |
failedUrl | string | No | Redirect on failed payment. |
expiredUrl | string | No | Redirect on expired invoice. |
metadata | object | No | Arbitrary key/value pairs stored with the invoice. |
tracking | object | No | Attribution: utm_source/medium/campaign/content/term, fbclid/fbc/fbp, gclid/gbraid/wbraid, ttclid, referrer. |
expiredAt | string | No | ISO 8601 datetime in UTC with a trailing Z (e.g. 2026-07-17T13:22:35Z). Timezone offsets like +07:00 are rejected. |
const body = JSON.stringify({
externalId: "order-123",
customerName: "Budi Santoso",
customerEmail: "budi@example.com",
customerPhone: "+628123456789",
itemName: "Pro Plan (1 tahun)",
amountCents: 1499000,
currency: "IDR",
paymentChannel: "QRIS",
items: [
{ name: "Pro Plan", quantity: 1, unitPriceCents: 1299000 },
{ name: "Add-on Seat", quantity: 2, unitPriceCents: 100000 }
],
successUrl: "https://app.example.com/thanks",
metadata: { plan: "pro" },
tracking: { utm_source: "newsletter", utm_campaign: "june" },
expiredAt: "2026-06-30T23:59:59Z"
});
// sign(...) then POST https://api.cart.kirimlead.com/api/v1/invoices
// headers: X-CartHub-Key, X-CartHub-Timestamp, X-CartHub-Signature
// optional: Idempotency-Key {
"invoiceId": "9f1c...",
"invoiceNumber": "INV-2026-0042",
"paymentUrl": "https://tripay.co.id/checkout/T0000...",
"payment": {
"provider": "tripay",
"channel": "BRIVA",
"reference": "T0000...",
"checkoutUrl": "https://tripay.co.id/checkout/T0000...",
"payUrl": null,
"payCode": "1234567890",
"qrUrl": null,
"amountCents": 1499000,
"feeMerchantCents": 425000,
"feeCustomerCents": 0,
"totalFeeCents": 425000,
"totalAmountCents": 1499000,
"amountReceivedCents": 1074000
},
"expiredAt": "2026-06-30T23:59:59Z",
"status": "pending"
} {
"error": {
"code": "BAD_REQUEST",
"message": "customerEmail: Invalid email"
}
} {
"error": {
"code": "UNAUTHORIZED",
"message": "Invalid signature"
}
} {
"error": {
"code": "FORBIDDEN",
"message": "Missing required scope: invoices:write"
}
}
For Tripay DIRECT channels use payment.payCode (VA/cashier) or
payment.qrUrl (QRIS). For REDIRECT channels send the customer to
payment.checkoutUrl/paymentUrl. payment.totalAmountCents
includes any customer fee or unique amount — store and display it exactly as returned.
GET /api/v1/invoices/:invoiceId
invoices:read Returns the full invoice object.
{
"id": "9f1c...",
"invoiceNumber": "INV-2026-0042",
"appId": "app_...",
"externalId": "order-123",
"customerName": "Budi Santoso",
"customerEmail": "budi@example.com",
"customerPhone": "+628123456789",
"itemName": "Pro Plan (1 tahun)",
"amountCents": 1499000,
"currency": "IDR",
"items": [
{
"name": "Pro Plan",
"description": null,
"quantity": 1,
"unitPriceCents": 1299000,
"totalCents": 1299000
}
],
"status": "pending",
"successUrl": null,
"failedUrl": null,
"expiredUrl": null,
"metadata": null,
"expiredAt": "2026-06-30T23:59:59Z",
"paidAt": null,
"cancelledAt": null,
"createdAt": "2026-06-23T10:00:00Z",
"updatedAt": "2026-06-23T10:00:00Z"
}
Statuses: pending, paid, failed,
expired, cancelled, waiting_confirmation,
under_review.
GET /api/v1/invoices/by-external/:externalId
invoices:read Same invoice object as above, looked up by your externalId. A GET signs over an empty body:
TS=$(date +%s)
PATH_Q="/api/v1/invoices/by-external/order-123"
EMPTY_SHA="e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
SIGNING_STRING=$(printf "GET\n%s\n%s\n%s" "$PATH_Q" "$TS" "$EMPTY_SHA")
SIG=$(printf "%s" "$SIGNING_STRING" \
| openssl dgst -sha256 -hmac "$CARTHUB_SECRET" \
| awk '{print $2}')
curl -s "https://api.cart.kirimlead.com$PATH_Q" \
-H "X-CartHub-Key: $CARTHUB_KEY" \
-H "X-CartHub-Timestamp: $TS" \
-H "X-CartHub-Signature: $SIG" POST /api/v1/invoices/:invoiceId/cancel
invoices:write Cancels a pending invoice. Returns the updated invoice with status: "cancelled".
{
"id": "9f1c...",
"invoiceNumber": "INV-2026-0042",
"status": "cancelled",
"cancelledAt": "2026-06-23T11:00:00Z"
} GET /api/v1/channels
invoices:read Lists the payment channels available on your account
so you can build your own channel picker. Pass a channel code to
paymentChannel when you create an invoice. Optional query param
?code= filters to a single channel.
{
"channels": [
{
"code": "BRIVA",
"name": "BRI Virtual Account",
"group": "Virtual Account",
"type": "virtual_account",
"iconUrl": "https://.../briva.png",
"feeMerchant": { "flat": 4250, "percent": 0 },
"feeCustomer": { "flat": 0, "percent": 0 },
"totalFee": { "flat": 4250, "percent": 0 },
"minimum": 10000,
"maximum": 50000000,
"active": true
},
{
"code": "QRIS2",
"name": "QRIS by ShopeePay",
"group": "E-Wallet",
"type": "e_wallet",
"iconUrl": "https://.../qris.png",
"feeMerchant": { "flat": 0, "percent": 0 },
"feeCustomer": { "flat": 0, "percent": 0.7 },
"totalFee": { "flat": 0, "percent": 0.7 },
"minimum": 1000,
"maximum": 10000000,
"active": true
}
]
} GET /api/v1/checkout/:invoiceId
public Hosted-checkout endpoints require no HMAC — the invoice id is an unguessable UUID that acts as a capability token. Returns a customer-safe view (no app id, metadata, or failed/expired URLs).
{
"invoiceId": "9f1c...",
"invoiceNumber": "INV-2026-0042",
"customerName": "Budi Santoso",
"customerEmail": "budi@example.com",
"itemName": "Pro Plan (1 tahun)",
"amountCents": 1499000,
"currency": "IDR",
"status": "pending",
"items": [
{ "name": "Pro Plan", "quantity": 1, "unitPriceCents": 1299000, "totalCents": 1299000 }
],
"paymentUrl": "https://tripay.co.id/checkout/T0000...",
"expiredAt": "2026-06-30T23:59:59Z",
"paidAt": null,
"manualTransferEnabled": true,
"successUrl": "https://app.example.com/thanks"
} POST /api/v1/checkout/:invoiceId/proof/sign
public Presign a manual-transfer proof upload. Allowed content types: image/jpeg, image/png, application/pdf.
{
"filename": "transfer-proof.jpg",
"contentType": "image/jpeg"
} {
"url": "https://...",
"key": "org_.../proof/uuid-transfer-proof.jpg",
"expiresAt": "2026-06-23T10:05:00Z",
"maxSizeBytes": 5242880
} POST /api/v1/checkout/:invoiceId/confirm-transfer
public Submit a manual bank-transfer confirmation. Returns 201. The proofFileKey must be the key returned by the presign step.
{
"proofFileKey": "org_.../proof/uuid-transfer-proof.jpg",
"bankName": "BCA",
"accountName": "Budi Santoso",
"amountClaimedCents": 1499000,
"notes": "Transferred at 10:03"
} {
"confirmationId": "mtc_...",
"status": "waiting_confirmation"
} GET /api/v1/checkout/:invoiceId/receipt
public Returns a presigned receipt PDF URL. Returns 409 until the invoice is paid.
{
"key": "org_.../INV-2026-0042-receipt.pdf",
"type": "receipt_pdf",
"downloadUrl": "https://..."
} {
"error": {
"code": "CONFLICT",
"message": "Receipt is only available for paid invoices"
}
} Webhooks
When an invoice changes state, CartHub POSTs a webhook to the webhookUrl
configured on your application. This is how your app learns a payment succeeded or
failed without polling. If no webhookUrl or no webhookSecret is
set on the application, delivery is skipped silently.
CartHub sends these headers:
| Header | Description |
|---|---|
Content-Type | application/json |
X-Webhook-Event | One of the event types below. |
X-Webhook-Timestamp | Unix seconds at send time. |
X-Webhook-Signature | Hex HMAC-SHA256 of {timestamp}.{rawBody}. |
X-Webhook-Id | Unique delivery id — use it for idempotency on your side. |
Payload
{
"id": "wh_01J...",
"eventType": "payment.paid",
"timestamp": "2026-06-23T10:05:12.000Z",
"data": {
"invoiceId": "9f1c...",
"invoiceNumber": "INV-2026-0042",
"externalId": "order-123",
"paymentStatus": "paid",
"amountCents": 1499000,
"currency": "IDR",
"providerReference": "T0000...",
"paidAt": "2026-06-23T10:05:11.000Z"
}
} Events
| Event | Sent when |
|---|---|
payment.created | Invoice created, pending payment. |
payment.pending | Payment registered but not yet settled (e.g. VA open). |
payment.paid | Payment confirmed — grant access / fulfill the order. |
payment.failed | Payment failed. |
payment.expired | Invoice expired unpaid. |
Refunded payments do not trigger a webhook.
Verifying the signature
Recompute the HMAC over the same {timestamp}.{rawBody} string and compare in constant time. Reject timestamps outside your replay window.
import { createHmac, timingSafeEqual } from "node:crypto";
function verifyWebhook(opts: {
rawBody: string; // exact bytes CartHub sent
timestamp: string; // X-Webhook-Timestamp header
signature: string; // X-Webhook-Signature header
webhookSecret: string;
replayWindowSeconds?: number;
}): boolean {
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - Number(opts.timestamp)) > (opts.replayWindowSeconds ?? 300)) {
return false;
}
const expected = createHmac("sha256", opts.webhookSecret)
.update(`${opts.timestamp}.${opts.rawBody}`)
.digest("hex");
const a = Buffer.from(expected);
const b = Buffer.from(opts.signature);
return a.length === b.length && timingSafeEqual(a, b);
} <?php
function verify_webhook(
string $rawBody, // exact bytes CartHub sent
string $timestamp, // X-Webhook-Timestamp header
string $signature, // X-Webhook-Signature header
string $webhookSecret,
int $replayWindowSeconds = 300
): bool {
if (abs(time() - (int) $timestamp) > $replayWindowSeconds) {
return false;
}
$expected = hash_hmac("sha256", $timestamp . "." . $rawBody, $webhookSecret);
return hash_equals($expected, $signature);
} import hashlib
import hmac
import time
def verify_webhook(
raw_body: str, # exact bytes CartHub sent
timestamp: str, # X-Webhook-Timestamp header
signature: str, # X-Webhook-Signature header
webhook_secret: str,
replay_window_seconds: int = 300,
) -> bool:
if abs(int(time.time()) - int(timestamp)) > replay_window_seconds:
return False
expected = hmac.new(
webhook_secret.encode(), f"{timestamp}.{raw_body}".encode(), hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature) package carthub
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strconv"
"time"
)
// rawBody is the exact bytes CartHub sent; timestamp/signature are the
// X-Webhook-Timestamp / X-Webhook-Signature headers.
func VerifyWebhook(rawBody, timestamp, signature, webhookSecret string, replayWindowSeconds int64) bool {
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return false
}
if now := time.Now().Unix(); now-ts > replayWindowSeconds || ts-now > replayWindowSeconds {
return false
}
mac := hmac.New(sha256.New, []byte(webhookSecret))
mac.Write([]byte(timestamp + "." + rawBody))
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))
} Retry behavior
- Respond with any
2xxto acknowledge. Anything else triggers a retry. - CartHub retries up to 5 times with exponential backoff. After the final attempt the delivery is marked
DEADand can be retried manually from the admin dashboard. - Deliveries are idempotent — a duplicate
X-Webhook-Idis safe to ignore if you already processed it.
POST /api/v1/callbacks/tripay
internal
Called by Tripay, not by integrating apps. Verified via Tripay's
X-Callback-Signature (HMAC of the raw body) and
X-Callback-Event: payment_status. Documented here for completeness — you do
not call this endpoint.