Send payouts to Haiti,
programmatically.
The ERTHA Partner API lets your platform deliver money to any recipient in Haiti in Gourdes (HTG) with a single signed request. Prepaid balance, sandbox testing, idempotent retries, and webhooks — built for businesses that move money at scale.
https://api.erthapay.com
From zero to first payout
Every account starts in Sandbox, where a fake balance lets you build and test with zero real money moving.
Create an account
Sign up for a partner account and receive your Sandbox key pair instantly.
Sign a request
Sign each call with HMAC-SHA256 using your secret. No secret ever leaves your server.
Go live
Fund your balance and switch to your Live keys. Your account manager enables Live payouts.
Signing requests
Requests are authenticated with an HMAC-SHA256 signature — there are no bearer tokens and your secret is never transmitted. Each request carries three headers:
| Header | Value |
|---|---|
| x-partner-key | Your public key (pk_test_… / pk_live_…) |
| x-partner-timestamp | Current time in milliseconds. Must be within ±5 minutes. |
| x-partner-signature | Hex HMAC-SHA256 of the signing string, keyed by your secret. |
The signing string
Concatenate the timestamp, the HTTP method and path, and the raw request body — exactly as shown. For requests with no body (e.g. GET), the body is empty, so the string ends in a trailing dot.
Keep secrets in the environment. Read your key and secret from environment variables — never hard-code a secret in your source or ship it to a browser.
# signing string = "{ts}.{METHOD} {path}.{body}" TS=$(($(date +%s)*1000)) BODY='{"recipient":"+509XXXXXXXX","amount":1000,"reference":"order-1001"}' MSG="$TS.POST /transfers.$BODY" SIG=$(printf '%s' "$MSG" | openssl dgst -sha256 \ -hmac "$ERTHA_API_SECRET" | sed 's/^.* //') curl https://api.erthapay.com/transfers \ -H "content-type: application/json" \ -H "x-partner-key: $ERTHA_API_KEY" \ -H "x-partner-timestamp: $TS" \ -H "x-partner-signature: $SIG" \ -d "$BODY"
import crypto from 'node:crypto'; const ts = Date.now().toString(); const body = JSON.stringify({ recipient:'+509XXXXXXXX', amount:1000, reference:'order-1001' }); const msg = `${ts}.POST /transfers.${body}`; const sig = crypto.createHmac('sha256', process.env.ERTHA_API_SECRET) .update(msg).digest('hex'); await fetch('https://api.erthapay.com/transfers', { method:'POST', headers:{ 'content-type':'application/json', 'x-partner-key':process.env.ERTHA_API_KEY, 'x-partner-timestamp':ts, 'x-partner-signature':sig, }, body, });
import hashlib, hmac, json, os, time import requests ts = str(int(time.time() * 1000)) body = json.dumps({"recipient":"+509XXXXXXXX","amount":1000,"reference":"order-1001"}, separators=(",",":")) msg = f"{ts}.POST /transfers.{body}" sig = hmac.new(os.environ["ERTHA_API_SECRET"].encode(), msg.encode(), hashlib.sha256).hexdigest() requests.post("https://api.erthapay.com/transfers", data=body, headers={ "content-type":"application/json", "x-partner-key":os.environ["ERTHA_API_KEY"], "x-partner-timestamp":ts, "x-partner-signature":sig, })
Sandbox & Live
Two isolated key pairs. Which key signs the request decides the mode — the same endpoints serve both.
pk_test_… / sk_test_…A fake balance and simulated payouts. Nothing real moves — perfect for building and CI. Recipients ending in 0000 simulate an invalid number.
pk_live_… / sk_live_…Real payouts drawn from your prepaid balance. Live delivery is enabled per account by your ERTHA account manager once you're ready.
Create a payout
Sends a payout to a recipient's mobile money number in Haiti. The amount is a whole number of Gourdes. Provide your own reference — it's how you retrieve status later and how retries stay safe.
| Body param | Type | |
|---|---|---|
| recipient | string | Required |
Recipient mobile number in +509XXXXXXXX format. | ||
| amount | integer | Required |
| Amount in whole HTG (Gourdes). Must be a positive integer. | ||
| reference | string | Required |
| Your unique id for this payout. Reuse it to retry safely. | ||
{
"status": "accepted",
"reference": "order-1001",
"transaction_id": "txn_9f3c1a20e5b74c1d8a2f6b90",
"payout_status": "pending",
"fee": 15,
"total_debited": 1015,
"balance": 95364,
"currency": "HTG",
"created_at": "2026-08-07T12:00:00Z",
"estimated_processing_time_seconds": 5
}
Statuses. payout_status starts pending, may pass through processing, and settles at paid or failed (review_required means a person is checking it). pending and processing are not final — confirm the outcome with Retrieve or a verified webhook, never from this initial response alone. Sandbox adds "livemode": false, "test": true.
Retrieve a payout
Fetch the current, authoritative state of a payout by the reference you created it with — or by its transaction_id (a segment starting txn_ is looked up as the transaction id, otherwise as your reference). Read-only and safe to poll. This endpoint is the source of truth for an outcome.
| Path param | Type | |
|---|---|---|
| reference | string | Required |
Your reference, or the txn_… transaction id. | ||
{
"reference": "order-1001",
"transaction_id": "txn_9f3c1a20e5b74c1d8a2f6b90",
"status": "paid",
"amount": 1000,
"fee": 15,
"currency": "HTG",
"created_at": "2026-08-07T12:00:00Z",
"settled_at": "2026-08-07T12:00:04Z"
}
Statuses. pending · processing · paid · failed · review_required. Only paid and failed are terminal; review_required is under manual review. Keep polling (or wait for a webhook) until a terminal state.
Validate a recipient
Confirm a recipient number resolves to a real account and return the registered name — check before you send, and show your user who they're paying.
| Body param | Type | |
|---|---|---|
| recipient | string | Required |
{
"recipient": "+509XXXXXXXX",
"name": "Jean Baptiste",
"livemode": true
}
Get balance
Your available prepaid balance, in Gourdes. In Sandbox this returns your fake test balance.
{
"balance": 96379,
"currency": "HTG",
"livemode": true
}
Idempotency
Networks fail. Retries shouldn't cost money twice.
Every payout carries your reference. If a request times out, re-send the same reference with the same body — you'll get the original payout back, never a duplicate. Re-using a reference with a different amount or recipient is rejected with 409 DUPLICATE_REQUEST, so a mistaken reuse can't silently overwrite a real payment. An optional Idempotency-Key header is supported, but if you send it, it must equal the body reference (otherwise 400 IDEMPOTENCY_KEY_MISMATCH).
A timeout or a 503 is not a failure — the payout may have succeeded. See Uncertain outcomes.
Uncertain outcomes
A timed-out request or a 503 tells you nothing about the payout itself — it may already have gone through. Resolve the ambiguity before you move any more money.
| Rule | Why |
|---|---|
pending and processing are not final | Only paid and failed are terminal (review_required means a person is checking). Never treat accepted, pending, or processing as delivered. |
| Retry with the same reference and body | On a network timeout or 503 SERVICE_UNAVAILABLE, resend the identical request. Idempotency returns the original payout — it never creates a second one. |
| Never mint a new reference to retry | A new reference is a new payment. Retrying an uncertain payout under a fresh reference can pay the recipient twice. |
| Resolve only via authenticated retrieval or a verified webhook | The true state comes from an authenticated GET /transfers/{reference}, or a signature-verified webhook whose reference/transaction_id you matched to your record. Nothing else. |
| Reconcile before any further money action | Confirm the real terminal state before you refund, re-send, or release goods/credit to your user. |
Retryable vs. not. A 503 or timeout is safe to retry with the same reference. A 4xx (e.g. INVALID_AMOUNT, INVALID_RECIPIENT) is a rejected request that moved no money — fix the request; do not blind-retry.
Webhooks
When a payout reaches a terminal state, ERTHA POSTs a signed event to your endpoint. A webhook is a notification, not proof — verify its signature and reconcile against the API before acting on money.
Events
| Event | Meaning |
|---|---|
| payout.paid | The payout was delivered (terminal). |
| payout.failed | The payout could not be delivered (terminal). |
Only these two terminal events are sent. In-flight states (pending, processing) and review_required do not emit a webhook — poll Retrieve for those.
Headers on the delivery
| Header | Value |
|---|---|
| x-ertha-event | payout.paid / payout.failed |
| x-ertha-event-id | Stable event id — your deduplication key. |
| x-ertha-timestamp | Send time, unix milliseconds (refreshed each retry). |
| x-ertha-signature | Hex HMAC-SHA256 (see below). |
| x-ertha-signature-version | v1 |
| x-ertha-key-version | Which webhook-key version signed it (rotation). |
Signature & raw-body verification
Events are signed with your dedicated webhook secret (whsec_…) — not your API secret. The signature is HMAC-SHA256, hex-encoded, over the exact string <x-ertha-timestamp>.<raw body>. Verify against the raw bytes you received, before parsing JSON — re-serializing the parsed object can change the bytes and break the signature.
Replay window. Record x-ertha-timestamp and reject deliveries far outside your own clock-skew tolerance (e.g. ±5 min). ERTHA stamps a fresh timestamp on every retry, so this is a client-side guard you enforce.
{
"event": "payout.paid",
"event_id": "9f3c1a20-4e2b-4c1d-8a2f-6b90e5b74c1d",
"reference": "order-1001",
"status": "paid",
"amount": 1000,
"fee": 15,
"currency": "HTG",
"created_at": "2026-08-07T12:00:00Z",
"settled_at": "2026-08-07T12:00:04Z"
}
import crypto from 'node:crypto'; // rawBody = the exact bytes of the POST body — do NOT re-serialize. function verifyErthaWebhook(headers, rawBody) { const ts = headers['x-ertha-timestamp']; const sig = headers['x-ertha-signature']; const expected = crypto.createHmac('sha256', process.env.ERTHA_WEBHOOK_SECRET) .update(`${ts}.${rawBody}`).digest('hex'); const ok = sig && crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)); if (!ok) throw new Error('bad signature'); // freshness guard (choose your own tolerance) if (Math.abs(Date.now() - Number(ts)) > 300000) throw new Error('stale'); return JSON.parse(rawBody); // parse only AFTER verifying }
Delivery, retries & ordering
| Behaviour | Detail |
|---|---|
| Acknowledge | Return 2xx quickly. Any other status (or no response within 10s) counts as a failure and is retried. |
| Retry schedule | Fixed backoff after each failure: 1, 5, 15, 60, then 240 minutes. |
| Dead-letter | After 6 failed attempts the event is marked dead and no longer retried — reconcile it via Retrieve. |
| At-least-once | The same event may arrive more than once. Deduplicate on x-ertha-event-id (stable across retries). |
| No ordering guarantee | A retried earlier event can land after a later one. Don't assume delivery order equals event order — use created_at/settled_at and the authoritative payout state. |
| HTTPS only | ERTHA delivers only to https:// endpoints. |
A webhook alone is not proof. Act only on an event whose signature you verified and whose reference/transaction_id you matched to a payout in your own records. Before releasing goods or crediting a user, confirm the state with GET /transfers/{reference}. An unverified or unmatched webhook must never trigger a money action.
In Sandbox you can fire a test payout.paid from your dashboard. The test payload carries "test": true and no body event_id — use the x-ertha-event-id header for dedup.
Limits & formats
The exact shapes, bounds, and rate limits the API enforces.
Fields & formats
| Field | Rule |
|---|---|
| x-partner-timestamp | Unix milliseconds. Must be within ±5 minutes of server time, else 401 TIMESTAMP_INVALID. |
| reference | String, ≤ 128 chars. Required, and must be present in the signed body. |
| recipient | Haiti mobile number, ≤ 20 chars. Digits are extracted: 8 digits get a 509 prefix; or pass the full 11-digit 509XXXXXXXX (e.g. +509XXXXXXXX). Anything else → INVALID_RECIPIENT. |
| amount | Positive integer HTG (no decimals/strings). Minimum 21 HTG; maximum 100,000 HTG per payout (the NatCash limit). Out of range → AMOUNT_BELOW_MIN / AMOUNT_ABOVE_MAX. |
| Idempotency-Key | Optional header; if present must equal reference. |
Signing string (exact)
| Request | String signed (HMAC-SHA256, hex) |
|---|---|
Action-body root POST / | <ts>.<rawBody> |
| REST route | <ts>.<METHOD> <path>.<rawBody> |
METHOD is upper-case; path is the route (e.g. /transfers); for a GET the body is empty, so the string ends in a trailing dot (e.g. <ts>.GET /balance.). Sign the exact raw bytes you send — the signature is byte-for-byte.
Rate limits
The default limit is 60 requests/minute per key (counted separately for Sandbox and Live); recipient validation is limited separately (10/min, 200/day). Over the limit returns 429 RATE_LIMITED.
Back-off signal. On 429, the wait time is a retry_after field (seconds) in the JSON body — there is no Retry-After HTTP header. Wait that long, then retry with the same reference.
Errors & status codes
Errors return a JSON body of the shape { "error": "CODE" }. Never surface a raw error to your end user — map it to your own copy.
| HTTP | Code | Meaning |
|---|---|---|
| 401 | UNAUTHORIZED | Bad signature, wrong or unknown key. |
| 401 | TIMESTAMP_INVALID | Timestamp outside the ±5-minute window. |
| 402 | INSUFFICIENT_BALANCE | Prepaid balance too low for this payout + fee. |
| 403 | PARTNER_INACTIVE | Account not active, or Live not enabled yet. |
| 403 | IP_NOT_ALLOWED | Request IP is not in your allowlist. |
| 400 | INVALID_JSON | Body is not valid JSON. |
| 400 | INVALID_REQUEST | Missing or unknown fields in the body. |
| 400 | REFERENCE_REQUIRED | A signed reference is required to create a payout. |
| 400 | IDEMPOTENCY_KEY_MISMATCH | Idempotency-Key header ≠ body reference. |
| 400 | INVALID_AMOUNT | Amount is not a positive whole number of HTG. |
| 400 | AMOUNT_BELOW_MIN / AMOUNT_ABOVE_MAX | Amount outside the allowed range (min 21 HTG). |
| 400 | INVALID_RECIPIENT | Number is not a valid Haiti mobile number. |
| 404 | RECIPIENT_NOT_FOUND | Validate: the number did not resolve to an account. |
| 404 | NOT_FOUND | No payout for that reference/id, or unknown route. |
| 405 | METHOD_NOT_ALLOWED | Wrong HTTP method for the path. |
| 409 | DUPLICATE_REQUEST | Reference reused with a different payload. |
| 429 | RATE_LIMITED | Too many requests — wait the retry_after seconds in the body, then retry. |
| 503 | SERVICE_UNAVAILABLE | Temporary — safe to retry with the same reference. |
| 503 | MAINTENANCE_MODE | Temporarily paused — retry later. |
Codes are stable strings; treat any unlisted code as a generic failure and reconcile via Retrieve.