# Tranzak Payment Gateway — Integration Guide for AI Coding Agents Version 1.0 · 2026-10-04 · Base URL: https://tranzak.co > Résumé (FR) : ce document explique à un agent IA comment intégrer les paiements Tranzak (MonCash, NatCash, carte) > dans n'importe quel projet, quel que soit le langage. Tout ce qui est décrit ici existe réellement dans l'API ; > ce qui n'existe pas encore est listé en section 14. Lisez la section 1 (règles) avant d'écrire du code. You are reading the single source of truth for integrating **Tranzak**, a payment gateway for Haiti. Everything below is implemented and verified against the real API. If something is not in this document, **it does not exist — do not invent endpoints, fields or parameters.** Machine-readable contract: https://tranzak.co/openapi.json · index: https://tranzak.co/llms.txt --- ## 1. Rules for the agent (read first) 1. **Never put an API key or webhook secret in source code, in the repository, in the frontend, in logs, or in the chat.** Read them from environment variables / the platform's secrets manager: `TRANZAK_API_KEY`, `TRANZAK_WEBHOOK_SECRET`, `TRANZAK_BASE_URL`. If you need the human to provide a value, tell them which variable to set and where — never ask them to paste a secret into the conversation. Use fake placeholders (`tk_test_xxxxxxxx`) in examples and `.env.example`. 2. **Only test keys (`tk_test_...`).** Never ask for, create, or use a live key (`tk_live_...`). Going live is a separate step the account owner performs themselves (identity verification is required by Tranzak). 3. **A payment is confirmed only by server-side proof** (signed webhook, or `GET /payments/{id}` / `POST .../verify` returning `status: "completed"`). A browser redirect, a client-side callback, or a "success" query parameter is **never** proof. Never mark an order paid because the customer came back to your site. 4. **Compute the amount and currency on your server** from your own order data. Never trust an amount sent by the browser. 5. **Never run destructive database operations**: no `migrate:fresh`, `migrate:reset`, `db:wipe`, `prisma migrate reset`, `DROP DATABASE/SCHEMA`, `TRUNCATE`, mass deletes, or destructive rollbacks — not directly and not through test hooks, seed scripts or setup scripts (inspect them first). A general instruction to "integrate payments" does not lift this rule. If an instruction requires it, stop and propose a non-destructive alternative. 6. **Prefer additive changes**: new tables/columns, new files. Do not rewrite existing migrations. Before writing to any existing database, check which host and database name you are connected to. Propose schema changes to the human before applying them; migrations on production are the human's decision. 7. **Do not claim success you did not observe.** Run the sandbox scenarios of section 9 and report real results. 8. Scope: payments integration only. Do not modify unrelated parts of the project. If you notice a security problem in the payment path (exposed secret, trusting a client amount, unauthenticated order endpoints), report it with the evidence; do not claim a full security audit of the application. --- ## 2. What the human must do once (Tranzak dashboard) Tranzak cannot yet create accounts or keys for you automatically (see section 14). Ask the account owner to: 1. Create an account at https://tranzak.co/register and log in. 2. Open **Developer** (https://tranzak.co/customer/developer) and create a **Website**: name, domain, **`callback_url`** (required — the **public HTTPS** URL of your webhook endpoint: a real domain name, not `http://`, not `localhost`, not an IP address or private network), optional `return_url` (where the customer lands after paying). If you only test locally and do not have a public URL yet, use a placeholder such as `https://example.com/webhook` and read results through the events feed (section 9.4) — change it to the real URL later. 3. Generate a **test API key** (`tk_test_...`). It is shown once. Store it as `TRANZAK_API_KEY` in the project's environment/secrets. Copy the website's **webhook secret** into `TRANZAK_WEBHOOK_SECRET`. 4. Keep the dashboard switch on **Test** while developing. Live mode unlocks only after Tranzak approves the owner's identity verification. Local development note: Tranzak's servers cannot call `http://localhost` and refuse non-public webhook URLs. See section 9.4 (events feed / tunnel). --- ## 3. Conventions | Item | Value | |---|---| | Base URL | `https://tranzak.co` — every endpoint below is under `/api/gateway/v1/` (not `/v1/`, not `/api/v1/`) | | Auth header | `X-Api-Key: ` on every request | | Body | JSON, `Content-Type: application/json`, `Accept: application/json` | | Envelope | Most responses: `{ "success": true\|false, ... }`. **`POST /payments` returns its fields at the root; every other endpoint wraps in `data`.** | | Amounts | In the currency's real unit (500 = 500 HTG, not cents). **Returned as strings** (`"250.00"`) — parse as decimal, never compare to a float. | | Currencies | `HTG`, `USD`. **MonCash and NatCash are paid in gourdes**: a `USD` amount is converted to HTG at the Tranzak rate before the payment is created (see `conversion` in the response). | | IDs | `transaction_id` is a numeric **string**. | | Timestamps | ISO 8601 UTC (`2026-10-04T14:00:00.000000Z`) | | Rate limit | 60 requests/minute per IP → HTTP 429 `{ "error": "rate_limit_exceeded", "retry_after": }`. 10 invalid-key attempts/hour/IP → 429 `too_many_attempts`. Back off; never retry an invalid key in a loop. | | Idempotency | **There is no idempotency key, and `reference` is not enforced unique.** See 8.4. | Auth errors: `401 API key is required` · `401 Invalid API key` · `403 API key is not active` · `403 Website not found or inactive`. --- ## 4. Endpoints ### 4.1 Create a payment — `POST /api/gateway/v1/payments` | Field | Type | Required | Notes | |---|---|---|---| | `amount` | number | yes | ≥ 1; method minimums apply (section 5) | | `currency` | string | yes | `HTG` or `USD` | | `payment_method` | string | yes | `moncash`, `natcash`, `lakaypay_card`, `card` (see section 5; `crypto` is not generally available) | | `customer_name` | string | yes | stored as-is | | `customer_email`, `customer_phone` | string | no | | | `reference` | string | no | **Your order id.** Strongly recommended — it is how you look the payment up later | | `description` | string | no | | | `metadata` | object | no | returned as-is in reads and webhooks | | `recurring`, `recurring_interval` (`daily`/`weekly`/`monthly`/`yearly`), `recurring_interval_count` | | no | `lakaypay_card` only — see section 5.3 | Response `201` (root level, not wrapped): ```json { "success": true, "transaction_id": "17843268616389", "amount": "500.00", "currency": "HTG", "status": "processing", "created_at": "2026-10-04T14:00:00.000000Z", "payment_url": "https://…" } ``` Redirect the customer's browser to `payment_url` (except `card`, which has no redirect — section 5.4). Method-specific extra fields are merged at the root. **Store `transaction_id` against your order before redirecting.** Errors: `422 validation_error` (see `errors`) · `400 payment_method_not_available` · `400 amount_too_low` / `amount_too_high` (message states the limit) · `403 payment_method_not_approved` (live only, `card`/`crypto`) · `400 payment_processing_failed` (provider refused; show `message`; includes `test_mode`/`simulated`) · `500 internal_error` (retry later; check section 8.4 before retrying a create). ### 4.2 Read a payment — `GET /api/gateway/v1/payments/{transaction_id}` ### 4.3 Read by your reference — `GET /api/gateway/v1/payments/reference/{reference}` ```json { "success": true, "data": { "transaction_id": "17843268616389", "amount": "500.00", "currency": "HTG", "status": "completed", "payment_method": "moncash", "reference": "ORDER-4821", "description": null, "customer": { "name": "Jean Dupont", "email": null, "phone": null }, "created_at": "…", "completed_at": "…", "failed_at": null, "failure_reason": null, "metadata": null } } ``` `404 transaction_not_found` if it does not exist or belongs to another website. ### 4.4 Actively re-check — `POST /api/gateway/v1/payments/{transaction_id}/verify` Asks the provider for the latest status, then answers `{ "success": true, "data": { "transaction_id", "status", "verified_at", "provider_response" } }`. **Use `data.status` only** (`pending|processing|completed|failed`); `provider_response` has provider-specific vocabulary — ignore it. Use this for reconciliation and for polling. ### 4.5 List — `GET /api/gateway/v1/payments` Query: `status`, `payment_method`, `from_date`, `to_date` (`YYYY-MM-DD`), `per_page` (1–100, default 20). Returns `{ "success": true, "data": [ …same shape as 4.2… ], "pagination": { "current_page", "per_page", "total", "last_page" } }`. ### 4.6 Events feed (sandbox only) — `GET /api/gateway/v1/events` Query: `after` (cursor from the previous call's `next_cursor`), `limit` (1–100, default 50). Only with a `tk_test_` key (live keys get `403 events_test_only`). Returns finished test payments as events, oldest first: ```json { "success": true, "data": [ { "id": "evt_17843268616389_success", "event": "payment.success", "created_at": "…", "data": { …same shape as 4.2 data… } } ], "next_cursor": "MjAyNi0xMC0wNCAxNDowMDowMHwxMjM", "has_more": false } ``` Same event names and `data` as webhooks (section 7). Keep `next_cursor`; call again later with `after=`. Invalid cursor → `422 invalid_cursor`. This exists so you can test without a public webhook URL. ### 4.7 Other `GET /api/gateway/v1/exchange-rate[?amount=500]` → `data.rate` (HTG per USD), `data.conversion`, `data.card_minimum` (`{"HTG": 65, "USD": 0.5}`). `GET /api/gateway/v1/health` → `{ "success": true, "service", "version", "timestamp" }`. Subscriptions (`GET /subscriptions`, `GET /subscriptions/{id}`, `POST /subscriptions/{id}/cancel`) and MonCash payouts (`/transfers`, `/prefund/balance`) exist but are outside this guide. --- ## 5. Payment methods | `payment_method` | Currency | Minimum | Flow | Live access | |---|---|---|---|---| | `moncash` | HTG (a USD amount is converted to HTG) | set by Tranzak (currently 10 HTG) | redirect to MonCash | open | | `natcash` | HTG (a USD amount is converted to HTG) | 20 HTG | redirect to NatCash | open | | `lakaypay_card` | HTG/USD | set by Tranzak | redirect (or overlay) to a Tranzak-hosted card page | open | | `card` (Visa/Mastercard, Stripe) | HTG/USD | **0.50 USD** (65 HTG at rate 130; see `card_minimum`) | embedded widget, no redirect | owner approval required for live | **MonCash / NatCash with `currency: "USD"`**: Tranzak converts at its rate (`exchange-rate` endpoint; USD × rate, rounded up to the next gourde), creates and charges the payment **in HTG**, and returns what it did. Example: `amount: 30, currency: "USD"` at rate 130 → the customer pays **3,900 HTG** on MonCash; the response (and later reads/webhooks) show `amount: 3900`, `currency: "HTG"` and `conversion: { original_amount: 30, original_currency: "USD", exchange_rate: 130, charged_amount: 3900, charged_currency: "HTG" }` (also kept in `metadata.tranzak_conversion`). Limits and fees apply to the HTG amount. Always read `currency` from the response, not from your request. Do not hard-code fees or limits that may change: read `422/400` messages and `exchange-rate.card_minimum`. ### 5.1 MonCash and NatCash Create the payment, redirect the browser to `payment_url`, the customer pays on the provider's screen and is sent back to your `return_url`. NatCash has **no instant notification**: if the customer closes the tab after paying, Tranzak checks NatCash every 2 minutes and then fires your webhook. Never assume a NatCash payment failed because the customer did not return — poll (section 6). ### 5.2 Tranzak-hosted card page (`lakaypay_card`) `payment_url` is a Tranzak page (valid ~12 minutes, single use) where the customer enters the card and confirms by email code. Your server never sees card data. Open it by redirect, or in a modal with `https://tranzak.co/js/tranzak-checkout.js`: `TranzakCheckout.open({ paymentUrl, mode: 'modal' | 'popup', onSuccess, onError, onClose })` (UX only — not proof). ### 5.3 Recurring (`lakaypay_card` only) Send `recurring: true` plus `recurring_interval`. A subscription is created only after the customer's first successful card payment; its `subscription_id` (`SUB_…`) arrives in `metadata.subscription_id` of the `payment.success` webhook — **store it immediately**. Each cycle is a new transaction with its own webhook. ### 5.4 Stripe card widget (`card`) `POST /payments` with `payment_method: "card"` returns `client_secret`, `publishable_key`, `payment_intent_id` (no `payment_url`). In the browser load `https://tranzak.co/js/tranzak-card.js`, then `TranzakCard.mount({ containerId, clientSecret, publishableKey })` and `TranzakCard.confirm({ onSuccess, onError })`. HTG amounts are converted to USD at the rate returned by `/exchange-rate`. With a test key this is real Stripe test mode (use Stripe test cards, e.g. `4242 4242 4242 4242`). Card declines are returned with Stripe's customer-safe message; other provider errors return a generic "temporarily unavailable" message. --- ## 6. Confirming a payment (statuses) | `status` | Meaning | Final? | |---|---|---| | `pending` / `processing` | created, customer has not finished | no | | `completed` | paid | **yes** | | `failed` | refused, abandoned, or expired | **yes** | A transaction left in `processing` for **15 minutes** is automatically marked `failed` (unless it was paid in the meantime — Tranzak checks MonCash/NatCash first). **You may not receive a webhook for an expired transaction**, so your code must also reconcile. Recommended algorithm (works in any language): 1. On create: save `{order_id, transaction_id, status: "pending"}`. 2. Primary: handle the signed webhook (section 7). 3. Safety net: when the customer returns to `return_url`, and on a timer for orders still pending (for example every minute for ~20 minutes, then once), call `GET /payments/{transaction_id}` — or `POST .../verify` to force a provider check — and apply `completed` / `failed` exactly once. 4. Make "mark order paid" **idempotent** (only the first transition to paid has effects: stock, emails, access). 5. Before fulfilling, check that `data.amount`/`currency` match the order and `reference` matches your order id. --- ## 7. Webhooks Configured per website (`callback_url` + secret, in the dashboard). Events: `payment.success`, `payment.failed`. Tranzak sends `POST` JSON with headers: ``` Content-Type: application/json X-Tranzak-Signature: X-Tranzak-Event: payment.success X-Tranzak-Timestamp: 1791122041 User-Agent: Tranzak-Webhook/1.0 ``` Body: ```json { "event": "payment.success", "timestamp": 1791122041, "data": { "transaction_id": "17843270387081", "amount": "250.00", "currency": "HTG", "status": "completed", "payment_method": "natcash", "reference": "ORDER-4821", "description": null, "customer": { "name": "Jean Dupont", "email": null, "phone": null }, "created_at": "…", "completed_at": "…", "failed_at": null, "failure_reason": null, "metadata": null } } ``` ### 7.1 Verify the signature (mandatory) `signature = hex( HMAC-SHA256( key = TRANZAK_WEBHOOK_SECRET, message = the RAW request body bytes ) )`. Use the raw body exactly as received — do not parse and re-serialize. Compare in constant time. Reject with 403 if it does not match. Then parse the JSON. ```php // PHP $ok = hash_equals(hash_hmac('sha256', $rawBody, $secret), $signatureHeader); ``` ```js // Node.js (Express: use express.raw({type:'application/json'}) on this route to get the raw Buffer) const crypto = require('crypto'); const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex'); const ok = signature.length === expected.length && crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature)); ``` ```python # Python import hmac, hashlib ok = hmac.compare_digest(hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest(), signature) ``` ```go // Go mac := hmac.New(sha256.New, []byte(secret)); mac.Write(rawBody) ok := hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(signature)) ``` ```ruby # Ruby ok = Rack::Utils.secure_compare(OpenSSL::HMAC.hexdigest('SHA256', secret, raw_body), signature) ``` ```java // Java Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(secret.getBytes(UTF_8), "HmacSHA256")); boolean ok = MessageDigest.isEqual(HexFormat.of().formatHex(mac.doFinal(rawBody)).getBytes(), signature.getBytes()); ``` ### 7.2 Reliability rules - Answer **HTTP 2xx quickly** (do heavy work afterwards). A non-2xx or timeout (15 s) is retried a limited number of times (3 attempts) with growing delays. - **Deduplicate**: the same event can arrive more than once. Key on `data.transaction_id` + `event`, and make the order transition idempotent (section 6.4). - **Retries reuse the original `timestamp`.** Do **not** reject events because the timestamp is "old" — that would drop legitimate retries. Idempotency is your replay protection; for extra certainty re-fetch `GET /payments/{transaction_id}`. - Exclude the webhook route from CSRF protection and from authentication middleware (it is authenticated by the signature). - Never log the secret or the full signature header. --- ## 8. Customer return (`return_url`) and idempotency ### 8.1 What Tranzak does After the provider page, the browser is redirected to the website's `return_url` with query parameters: ``` https://yoursite.com/pay/return?transaction_id=17843268616389&status=success&amount=500.00¤cy=HTG×tamp=1791122041&reference=ORDER-4821&signature= ``` `status` is `success` when the transaction is `completed`, otherwise `failed` — **`failed` also appears when the payment is simply not confirmed yet** (for example NatCash still pending). So treat this page as "payment in progress / checking", then confirm server-side (section 6). ### 8.2 Verify the return signature The signed message is the **query string exactly as received, without the final `&signature=`**, in the **original parameter order** (do not sort). `signature = hex(HMAC-SHA256(TRANZAK_WEBHOOK_SECRET, thatString))`. Implementation: take the raw query string, cut it at `&signature=`, HMAC the left part, compare in constant time. A valid signature only proves the URL came from Tranzak — it still does not replace the server-side confirmation of section 6. ### 8.3 Local `return_url` The redirect is performed by the customer's browser, so `http://localhost:3000/...` works for testing. ### 8.4 No idempotency key Retrying `POST /payments` after a timeout can create a second payment. Protect yourself: send a unique `reference` per order and, before creating again, call `GET /payments/reference/{reference}`; reuse an existing non-final payment instead of creating a new one. Disable the pay button after the first click. --- ## 9. Sandbox (test key `tk_test_…`) No real money moves. Behavior differs per method — know it before writing tests: | Method | Create | How the payment becomes `completed` | Webhook fired? | Failure test | |---|---|---|---|---| | `moncash` | `201`, `test_mode: true, simulated: true`, `payment_url` is a MonCash sandbox URL that does nothing | call `POST /payments/{id}/verify` | **no** | amount `1` → `400 payment_processing_failed` ("TEST MODE: Simulated payment failure") | | `natcash` | `201`, `payment_url` is a Tranzak URL | open `payment_url` in the browser (or `GET` it): payment is completed and you are redirected to `return_url`; or call `verify` | **yes** if opened via `payment_url`; no for `verify` | amount `1` is rejected by the 20 HTG minimum — no simulated failure | | `lakaypay_card` | `201`, hosted page opens | the page simulates confirmation, no card needed | yes | amount `1` → `400` as above | | `card` | real Stripe test mode, `client_secret` | Stripe test cards via the widget | yes (through Stripe) | Stripe decline test cards (e.g. `4000 0000 0000 0002`) | ### 9.1 Scenarios to run before reporting "done" 1. Successful payment per method you enable. 2. Failed payment (where a failure test exists) — the order stays unpaid. 3. Delayed confirmation: customer returns before it is confirmed → your page shows "checking", polling later completes it. 4. Webhook: valid signature accepted; **tampered body rejected (403)**; same event delivered twice → one fulfillment. 5. Return URL with a modified `amount` or invalid `signature` → rejected / ignored. ### 9.2 Testing the webhook handler without Tranzak Build a signed request yourself: compute the HMAC of a sample body (section 7.1) with a test secret and POST it to your endpoint. This covers MonCash, which fires no sandbox webhook. ### 9.3 Test data Test transactions are purged by Tranzak in a nightly maintenance job. Do not depend on test data persisting for more than a day. ### 9.4 Local development (no public URL) - **Preferred:** poll `GET /payments/{id}` (or `verify`) and/or the events feed (4.6), feeding events to the **same handler function** you use for real webhooks. - **Real webhooks locally:** expose your server with an HTTPS tunnel (ngrok, Cloudflare Tunnel…) and set that URL as the website `callback_url` in the dashboard. - Tranzak only sends webhooks to **public** addresses. A new `callback_url` must be `https://` on a public domain name; URLs that point to `localhost`, private networks, link-local/cloud-metadata addresses, or raw IPs are refused at registration, and any webhook aimed at a non-public address is blocked at send time (no retry). Use a tunnel (above) for real webhooks during local development. --- ## 10. Error catalog | HTTP | `error` | What to do | |---|---|---| | 401 | `API key is required` / `Invalid API key` | fix `TRANZAK_API_KEY`; do not loop | | 403 | `API key is not active` / `Website not found or inactive` | ask the owner to reactivate in the dashboard | | 403 | `payment_method_not_approved` | live only; owner requests access in dashboard › Payment methods | | 403 | `events_test_only` | events feed needs a test key | | 404 | `transaction_not_found` | wrong id/reference, or another website's payment | | 422 | `validation_error` / `invalid_cursor` | fix the request; see `errors` | | 400 | `payment_method_not_available` | method not enabled for this account | | 400 | `amount_too_low` / `amount_too_high` | adjust amount; message states the limit | | 400 | `payment_processing_failed` | show `message` to the customer; offer retry | | 429 | `rate_limit_exceeded` / `too_many_attempts` | wait `retry_after` seconds | | 500 | `internal_error` | retry with backoff; check 8.4 before re-creating | --- ## 11. Blueprint — what to build in the target project 1. **Config**: read `TRANZAK_BASE_URL`, `TRANZAK_API_KEY`, `TRANZAK_WEBHOOK_SECRET` from the environment; add them (empty/fake) to `.env.example`; make sure `.env` is git-ignored. 2. **Storage** (additive migration, ask first): on your orders table add `payment_status`, `tranzak_transaction_id` (string, indexed), `paid_at`; optionally a table of processed webhook events `(transaction_id, event)` with a unique index. 3. **Create-payment endpoint (server)**: loads the order, computes amount/currency itself, calls `POST /payments`, stores `transaction_id`, returns `payment_url` (or the card widget parameters) to the browser. 4. **Webhook endpoint (server)**: raw body → verify signature → parse → dedupe → fetch `GET /payments/{id}` to confirm → idempotent transition → return 200. 5. **Return page**: verify the signed query string, show "payment in progress", poll your own backend until the order is final. 6. **Reconciliation job**: periodically checks orders still pending (section 6.3). 7. **Tests**: the scenarios of 9.1 using sandbox keys and a throwaway/test database — never reset an existing database. --- ## 12. Review checklist (payment path only) - [ ] No key/secret in code, frontend bundle, repo history, logs, or error messages - [ ] Amount/currency computed server-side; order id from your own data - [ ] Orders are fulfilled only after server-side confirmation, exactly once - [ ] Webhook: raw-body HMAC check, constant-time compare, 2xx fast, deduplicated - [ ] Return URL signature verified; status never trusted by itself - [ ] Pending orders are reconciled (no webhook is guaranteed for expired payments) - [ ] Unique `reference` per order; double-click on "Pay" cannot create two payments - [ ] Errors from section 10 handled explicitly; customer-facing messages contain no technical detail - [ ] Rate-limit (429) handled with backoff --- ## 13. Final report to give the human List: files changed/added · environment variables to set (names only) · migrations prepared (applied or not) · sandbox scenarios run with real results · what remains for production (owner generates live key, identity verification, live webhook URL, live test with a small amount) · any security finding in the payment path with evidence (without reproducing secrets) · what you could not verify. --- ## 14. Not available yet (do not invent) - Automatic creation of accounts, websites, keys or webhook secrets by an agent (the owner does section 2). - Scoped/limited tokens, OAuth, MCP server, CLI. - Idempotency keys; unique `reference` enforcement. - Refund endpoint in the gateway API; webhook "send test event" endpoint. - Sandbox webhooks for MonCash.