CartHub
Menu · On this page
CartHub API

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.

Base URL
https://api.cart.kirimlead.com
  • All request and response bodies are JSON (Content-Type: application/json).
  • Money is always integer centsamountCents: 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.

  1. Sign in to the CartHub dashboard with an admin account.
  2. Open ApplicationsNew application and fill in the name, code, webhook URL, webhook secret (a string of 16+ characters you choose), default provider (tripay), and enabled providers.
  3. In the application, click Create API key. You will be shown two values once:
    • Key — looks like ch_live_AbCd1234…. Sent in the X-CartHub-Key header on every request.
    • Secret — looks like ch_secret_…. Used to compute the HMAC signature. Store it securely; it is never shown again.
  4. Copy both values into your backend environment variables (for example CARTHUB_API_KEY and CARTHUB_API_SECRET). Also store the webhook secret you chose in step 2 (for example CARTHUB_WEBHOOK_SECRET) — your webhook handler needs it to verify the X-Webhook-Signature header.

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:

HeaderDescription
X-CartHub-KeyPlaintext API key, e.g. ch_live_AbCd1234…. The first 12 chars are the lookup prefix.
X-CartHub-TimestampCurrent Unix time in seconds. Stale or far-future values are rejected with 401.
X-CartHub-SignatureLowercase hex HMAC-SHA256 of the signing string (below).

Signing string

Build a newline-separated string, then HMAC-SHA256 it with your API secret:

signing string
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 the X-CartHub-Timestamp header.
  • 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.

ScopeGrants
invoices:readRead invoices (GET endpoints).
invoices:writeCreate 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 };
}

Errors

All errors share one shape:

error
{
  "error": {
    "code": "UNAUTHORIZED",
    "message": "Invalid signature"
  }
}
StatusCodeMeaning
400BAD_REQUESTValidation failed.
401UNAUTHORIZEDMissing/invalid headers, key, signature, or timestamp.
403FORBIDDENApp inactive or missing scope.
404NOT_FOUNDResource not found.
409CONFLICTInvalid state transition, e.g. receipt before paid.
429RATE_LIMITEDToo many requests — see Rate limits.
503EXTERNAL_API_ERRORUpstream/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);

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.

ScopeLimitWindowKeyed by
/api/v1/*120 requests60 secondsX-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).

ScenarioResult
Same key, identical body200 — original response replayed, no new invoice
Same key, different body409 CONFLICTIdempotency-Key was already used with a different request body
No key201 — 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

FieldTypeRequiredDescription
externalIdstringYesYour order id (1–200 chars). Use it for idempotency and lookups.
customerNamestringYes1–200 chars.
customerEmailstringYesValid email, max 254.
customerPhonestringNoMatches ^\+?[0-9]{8,16}$.
itemNamestringYes1–200 chars.
amountCentsintegerYesPositive integer cents.
currencyenumNoIDR or USD. Defaults to IDR.
paymentChannelstringNoTripay method, e.g. QRIS, BRIVA, BCAVA. Falls back to the server default.
itemsarrayNoLine items: name, quantity (≥1), unitPriceCents (≥0), optional description, totalCents.
successUrlstringNoWhere to send the customer after a successful payment.
failedUrlstringNoRedirect on failed payment.
expiredUrlstringNoRedirect on expired invoice.
metadataobjectNoArbitrary key/value pairs stored with the invoice.
trackingobjectNoAttribution: utm_source/medium/campaign/content/term, fbclid/fbc/fbp, gclid/gbraid/wbraid, ttclid, referrer.
expiredAtstringNoISO 8601 datetime in UTC with a trailing Z (e.g. 2026-07-17T13:22:35Z). Timezone offsets like +07:00 are rejected.
Request body
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"
}

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.

200 · 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:

curl
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".

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

200 · channel list
{
  "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).

200 · checkout view
{
  "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.

Request body
{
  "filename": "transfer-proof.jpg",
  "contentType": "image/jpeg"
}
200
{
  "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.

Request body
{
  "proofFileKey": "org_.../proof/uuid-transfer-proof.jpg",
  "bankName": "BCA",
  "accountName": "Budi Santoso",
  "amountClaimedCents": 1499000,
  "notes": "Transferred at 10:03"
}
201
{
  "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.

200
{
  "key": "org_.../INV-2026-0042-receipt.pdf",
  "type": "receipt_pdf",
  "downloadUrl": "https://..."
}
409
{
  "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:

HeaderDescription
Content-Typeapplication/json
X-Webhook-EventOne of the event types below.
X-Webhook-TimestampUnix seconds at send time.
X-Webhook-SignatureHex HMAC-SHA256 of {timestamp}.{rawBody}.
X-Webhook-IdUnique delivery id — use it for idempotency on your side.

Payload

webhook body
{
  "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

EventSent when
payment.createdInvoice created, pending payment.
payment.pendingPayment registered but not yet settled (e.g. VA open).
payment.paidPayment confirmed — grant access / fulfill the order.
payment.failedPayment failed.
payment.expiredInvoice 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);
}

Retry behavior

  • Respond with any 2xx to acknowledge. Anything else triggers a retry.
  • CartHub retries up to 5 times with exponential backoff. After the final attempt the delivery is marked DEAD and can be retried manually from the admin dashboard.
  • Deliveries are idempotent — a duplicate X-Webhook-Id is 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.