ERTHAPartner API Log in Create account
Ertha Payment Solution

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.

Base URL https://api.erthapay.com
Quickstart

From zero to first payout

Every account starts in Sandbox, where a fake balance lets you build and test with zero real money moving.

1

Create an account

Sign up for a partner account and receive your Sandbox key pair instantly.

2

Sign a request

Sign each call with HMAC-SHA256 using your secret. No secret ever leaves your server.

3

Go live

Fund your balance and switch to your Live keys. Your account manager enables Live payouts.

Authentication

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:

HeaderValue
x-partner-keyYour public key (pk_test_… / pk_live_…)
x-partner-timestampCurrent time in milliseconds. Must be within ±5 minutes.
x-partner-signatureHex 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.

cURL Node.js Python
# 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"
Environments

Sandbox & Live

Two isolated key pairs. Which key signs the request decides the mode — the same endpoints serve both.

Sandboxpk_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.

Livepk_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.

Endpoint

Create a payout

POST/transfers

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 paramType
recipientstringRequired
Recipient mobile number in +509XXXXXXXX format.
amountintegerRequired
Amount in whole HTG (Gourdes). Must be a positive integer.
referencestringRequired
Your unique id for this payout. Reuse it to retry safely.
Response200 OK
{
  "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.

Endpoint

Retrieve a payout

GET/transfers/{reference}

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 paramType
referencestringRequired
Your reference, or the txn_… transaction id.
Response200 OK
{
  "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.

Endpoint

Validate a recipient

POST/recipients/validate

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 paramType
recipientstringRequired
Response200 OK
{
  "recipient": "+509XXXXXXXX",
  "name": "Jean Baptiste",
  "livemode": true
}
Endpoint

Get balance

GET/balance

Your available prepaid balance, in Gourdes. In Sandbox this returns your fake test balance.

Response200 OK
{
  "balance": 96379,
  "currency": "HTG",
  "livemode": true
}
Reliability

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.

Reliability

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.

RuleWhy
pending and processing are not finalOnly 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 bodyOn 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 retryA 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 webhookThe 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 actionConfirm 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.

Reliability

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

EventMeaning
payout.paidThe payout was delivered (terminal).
payout.failedThe 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

HeaderValue
x-ertha-eventpayout.paid / payout.failed
x-ertha-event-idStable event id — your deduplication key.
x-ertha-timestampSend time, unix milliseconds (refreshed each retry).
x-ertha-signatureHex HMAC-SHA256 (see below).
x-ertha-signature-versionv1
x-ertha-key-versionWhich 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.

Payload — payout.paid
{
  "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"
}
Verify (Node.js)
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

BehaviourDetail
AcknowledgeReturn 2xx quickly. Any other status (or no response within 10s) counts as a failure and is retried.
Retry scheduleFixed backoff after each failure: 1, 5, 15, 60, then 240 minutes.
Dead-letterAfter 6 failed attempts the event is marked dead and no longer retried — reconcile it via Retrieve.
At-least-onceThe same event may arrive more than once. Deduplicate on x-ertha-event-id (stable across retries).
No ordering guaranteeA 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 onlyERTHA 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.

Reference

Limits & formats

The exact shapes, bounds, and rate limits the API enforces.

Fields & formats

FieldRule
x-partner-timestampUnix milliseconds. Must be within ±5 minutes of server time, else 401 TIMESTAMP_INVALID.
referenceString, ≤ 128 chars. Required, and must be present in the signed body.
recipientHaiti 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.
amountPositive 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-KeyOptional header; if present must equal reference.

Signing string (exact)

RequestString 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.

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.

HTTPCodeMeaning
401UNAUTHORIZEDBad signature, wrong or unknown key.
401TIMESTAMP_INVALIDTimestamp outside the ±5-minute window.
402INSUFFICIENT_BALANCEPrepaid balance too low for this payout + fee.
403PARTNER_INACTIVEAccount not active, or Live not enabled yet.
403IP_NOT_ALLOWEDRequest IP is not in your allowlist.
400INVALID_JSONBody is not valid JSON.
400INVALID_REQUESTMissing or unknown fields in the body.
400REFERENCE_REQUIREDA signed reference is required to create a payout.
400IDEMPOTENCY_KEY_MISMATCHIdempotency-Key header ≠ body reference.
400INVALID_AMOUNTAmount is not a positive whole number of HTG.
400AMOUNT_BELOW_MIN / AMOUNT_ABOVE_MAXAmount outside the allowed range (min 21 HTG).
400INVALID_RECIPIENTNumber is not a valid Haiti mobile number.
404RECIPIENT_NOT_FOUNDValidate: the number did not resolve to an account.
404NOT_FOUNDNo payout for that reference/id, or unknown route.
405METHOD_NOT_ALLOWEDWrong HTTP method for the path.
409DUPLICATE_REQUESTReference reused with a different payload.
429RATE_LIMITEDToo many requests — wait the retry_after seconds in the body, then retry.
503SERVICE_UNAVAILABLETemporary — safe to retry with the same reference.
503MAINTENANCE_MODETemporarily paused — retry later.

Codes are stable strings; treat any unlisted code as a generic failure and reconcile via Retrieve.