FunTokenz Merchant API
A prepaid voucher system for merchants who want to accept deposits and issue withdrawals in USD or PGK without card infrastructure. Authenticated with HMAC-SHA256.
Deposit flow
Redeem a customer-supplied voucher code and credit their wallet.
Withdrawal flow
Debit a customer balance and issue a fresh FunTokenz voucher.
Webhooks
Receive signed events for redemptions, voids and reversals.
Base URLs
https://funtokenz.com/api/public/merchant/v1https://project--a8d167f0-2759-414d-9002-033c558df52b-dev.lovable.app/api/public/merchant/v1All amounts are integer minor units (cents for USD, toea for PGK). Supported currencies: USD, PGK.
0. Provisioning
Credentials are minted in the FunTokenz admin console. Each merchant receives:
merchant_id(slug, e.g.puntpal)api_key(public identifier)api_secret— HMAC signing key, shown oncewebhook_secret— shown once
Both secrets can be rotated from the admin console; rotation invalidates the previous secret immediately. Use separate merchant records for sandbox and production.
1. Authentication
Every request is authenticated with the merchant API key plus an HMAC-SHA256 signature over the raw request body.
Required headers
Content-Type: application/json
X-FTZ-Merchant-Id: puntpal
X-FTZ-Api-Key: pk_live_xxxxxxxxxxxx
X-FTZ-Timestamp: 1751500000 # Unix seconds, within ±300s of server time
X-FTZ-Nonce: 550e8400-e29b-41d4-a716-446655440000
X-FTZ-Signature: sha256=<hex-hmac>Signature construction
string_to_sign = X-FTZ-Timestamp + "." + X-FTZ-Nonce + "." + HTTP_METHOD + "." + REQUEST_PATH + "." + RAW_BODY
signature = hex( HMAC_SHA256( api_secret, string_to_sign ) )HTTP_METHODis uppercase (POST).REQUEST_PATHstarts with/api/public/merchant/v1/...and includes any query string.RAW_BODYis the exact request body bytes (empty string for GET).- The server compares signatures with a constant-time equality check.
Node.js example
import { createHmac, randomUUID } from "node:crypto";
const body = JSON.stringify(payload);
const ts = Math.floor(Date.now() / 1000).toString();
const nonce = randomUUID();
const stringToSign = `${ts}.${nonce}.POST./api/public/merchant/v1/deposit/redeem.${body}`;
const signature = createHmac("sha256", process.env.FTZ_API_SECRET!)
.update(stringToSign).digest("hex");
await fetch("https://funtokenz.com/api/public/merchant/v1/deposit/redeem", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-FTZ-Merchant-Id": "puntpal",
"X-FTZ-Api-Key": process.env.FTZ_API_KEY!,
"X-FTZ-Timestamp": ts,
"X-FTZ-Nonce": nonce,
"X-FTZ-Signature": `sha256=${signature}`,
},
body,
});Every response includes an X-FTZ-Request-Id header — log it against your transaction row for support.
2. Idempotency
State-changing endpoints require an idempotency_key UUID in the body.
- Retrying the same key returns the original response with
"idempotent_replay": true. - Reusing a key with a different payload returns
409 idempotency_conflict. merchant_txn_idis unique per merchant per endpoint — reusing it also returns409.
3.1 POST /deposit/verify (optional)
Look up a voucher without redeeming it — useful for previewing the amount before the customer confirms.
Request
{ "code": "FTZ-ABCD-EFGH-JKLM" }Response 200
{
"code": "FTZ-ABCD-EFGH-JKLM",
"status": "active",
"amount": 5000,
"currency": "USD",
"issued_at": "2026-07-10T09:12:33.000Z"
}Status values
| Status | Meaning |
|---|---|
| active | Redeemable |
| voided | Already spent or manually voided |
| expired | Past expires_at |
| unknown | Code was never issued |
3.2 POST /deposit/redeem
Atomically voids the voucher and records a redemption against the merchant. Call from the merchant backend only, never the customer's browser.
Request
{
"idempotency_key": "b6f3b0b2-8f5e-4a2d-9a0e-1c1c9a5b3e11",
"code": "FTZ-ABCD-EFGH-JKLM",
"customer_reference": "puntpal_user_9812",
"merchant_txn_id": "deposit_2026071011223301"
}Fields
| Field | Type | Required | Notes |
|---|---|---|---|
| idempotency_key | uuid | yes | See §2 |
| code | string | yes | Full voucher code, case-insensitive |
| customer_reference | string ≤ 128 | yes | Merchant-side user id |
| merchant_txn_id | string ≤ 64 | yes | Unique per merchant deposit id |
Response 200
{
"redemption_id": "6f0c8b3e-2ac1-4b7d-a5c8-2f1e3d4b5c67",
"code": "FTZ-ABCD-EFGH-JKLM",
"amount": 5000,
"currency": "USD",
"redeemed_at": "2026-07-10T11:22:33.412Z",
"idempotent_replay": false
}Common errors
| HTTP | error.code | Meaning |
|---|---|---|
| 404 | voucher_not_found | Code does not exist |
| 409 | voucher_already_redeemed | Code was already spent |
| 409 | voucher_voided | Manually voided by FunTokenz staff |
| 410 | voucher_expired | Past its expiry date |
| 409 | idempotency_conflict | Key or merchant_txn_id reused |
4.1 POST /withdrawal/issue
Debit the customer's balance and mint a fresh voucher code returned to the customer. If customer_email matches a FunTokenz account, the voucher is bound to that account; otherwise it is bearer.
Request
{
"idempotency_key": "d1a2c9f4-1e23-4a10-9b7f-2c0f4e6a01f2",
"amount": 5000,
"currency": "USD",
"customer_reference": "puntpal_user_9812",
"customer_email": "player@example.com",
"merchant_txn_id": "withdraw_2026071011400712"
}Fields
| Field | Type | Required | Notes |
|---|---|---|---|
| idempotency_key | uuid | yes | See §2 |
| amount | integer | yes | Minor units. USD: 100–500 000. PGK: 500–1 500 000 |
| currency | USD | PGK | yes | Must be enabled for the merchant |
| customer_email | string | no | Bound to matching FunTokenz account if found |
| customer_reference | string ≤ 128 | yes | Merchant user id |
| merchant_txn_id | string ≤ 64 | yes | Unique per merchant withdrawal id |
Response 200
{
"issuance_id": "2c1d4f60-b7a8-4e11-9d3a-4a1b6e2f8a09",
"code": "FTZ-QWER-TYUI-OPAS",
"amount": 5000,
"currency": "USD",
"expires_at": "2027-07-10T11:40:07.000Z",
"issued_at": "2026-07-10T11:40:07.412Z",
"idempotent_replay": false
}Merchant must
- Confirm the customer's balance covers
amount + feesbefore calling. - Debit the customer in the same DB transaction that stores
issuance_id/code. - Present
codeto the customer once. Never log it in plaintext outside the customer's account view.
Common errors
| HTTP | error.code | Meaning |
|---|---|---|
| 400 | invalid_amount | Below/above allowed range |
| 400 | currency_not_enabled | Merchant not enabled for that currency |
| 402 | merchant_credit_exhausted | Daily issuance limit reached |
| 409 | idempotency_conflict | Key or merchant_txn_id reused |
5. Webhooks (optional)
Configure a webhook URL when provisioning the merchant. FunTokenz posts JSON events signed with the merchant's webhook_secret.
- POST with JSON body.
- Headers include
X-FTZ-Webhook-Signature: sha256=<hex-hmac>andX-FTZ-Event-Id. - Endpoint must return 2xx within 5 s. Non-2xx responses are retried with exponential backoff up to 24 h.
- Handle events idempotently on
id.
Event types (planned)
| Event | When |
|---|---|
| voucher.redeemed | A voucher issued by this merchant was redeemed anywhere |
| voucher.voided | FunTokenz staff manually voided a voucher affecting this merchant |
| redemption.reversed | A previously accepted deposit was reversed — debit the customer |
Example payload
{
"id": "evt_01J8ZA1MK0X6C4C7Q4Q0F1RTPB",
"type": "voucher.redeemed",
"created_at": "2026-07-10T12:00:00.000Z",
"data": {
"issuance_id": "2c1d4f60-b7a8-4e11-9d3a-4a1b6e2f8a09",
"code": "FTZ-QWER-TYUI-OPAS",
"amount": 5000,
"currency": "USD",
"redeemed_at": "2026-07-10T11:59:58.000Z"
}
}Signature verification
import { createHmac, timingSafeEqual } from "node:crypto";
const raw = await request.text();
const sig = request.headers.get("x-ftz-webhook-signature")!.slice(7);
const expected = createHmac("sha256", process.env.FTZ_WEBHOOK_SECRET!)
.update(raw).digest("hex");
const ok = sig.length === expected.length &&
timingSafeEqual(Buffer.from(sig, "hex"), Buffer.from(expected, "hex"));6. Merchant credit / float
/withdrawal/issue is capped by a daily issuance limit per currency (defaults: 50 000 USD, 150 000 PGK). Contact FunTokenz admin to raise. Deposit endpoints have no cap beyond global rate limits.
7. Standard error shape
All 4xx / 5xx responses use this shape:
{
"error": {
"code": "voucher_already_redeemed",
"message": "This voucher has already been redeemed.",
"request_id": "req_5a3f0c8b3e2ac14b7da5c82f1e3d4b5c"
}
}HTTP status reference
| HTTP | Meaning |
|---|---|
| 400 | Malformed request / validation failure |
| 401 | Missing or invalid API key or signature |
| 403 | Merchant disabled |
| 404 | Resource not found |
| 409 | State conflict (already spent, idempotency mismatch) |
| 410 | Resource expired |
| 429 | Rate limited |
| 5xx | FunTokenz-side failure — retry with the same idempotency_key |
8. Currencies & amounts
| Currency | Min per voucher | Max per voucher | Minor unit |
|---|---|---|---|
| USD | 100 ($1.00) | 500 000 ($5 000.00) | cents |
| PGK | 500 (K5.00) | 1 500 000 (K15 000.00) | toea |
No FX conversion is performed. A PGK voucher must be issued and redeemed as PGK.
9. Security requirements
- Store
api_secretandwebhook_secretin a secrets manager. Never commit them. - Enforce HTTPS on the webhook endpoint; reject any request whose HMAC does not verify.
- Reject webhook requests whose
created_atis older than 5 minutes. - Do not expose FunTokenz API endpoints or credentials to browser code — all calls originate from the merchant backend.
- Log
X-FTZ-Request-IdandX-FTZ-Event-Idalongside every transaction for reconciliation.
10. Testing
Use a separate sandbox merchant record against the sandbox base URL. Issue a small voucher via /withdrawal/issue, then verify + redeem it via /deposit/verify and /deposit/redeem using its own code.
Curl smoke test for /deposit/verify
BODY='{"code":"FTZ-XXXX-XXXX-XXXX"}'
TS=$(date +%s)
NONCE=$(uuidgen)
PATH_="/api/public/merchant/v1/deposit/verify"
SIG=$(printf "%s.%s.POST.%s.%s" "$TS" "$NONCE" "$PATH_" "$BODY" \
| openssl dgst -sha256 -hmac "$FTZ_API_SECRET" | awk '{print $2}')
curl -s https://funtokenz.com$PATH_ \
-H "Content-Type: application/json" \
-H "X-FTZ-Merchant-Id: puntpal" \
-H "X-FTZ-Api-Key: $FTZ_API_KEY" \
-H "X-FTZ-Timestamp: $TS" \
-H "X-FTZ-Nonce: $NONCE" \
-H "X-FTZ-Signature: sha256=$SIG" \
-d "$BODY"11. Changelog
- v1.0 (2026-07-10) — Initial release: HMAC auth, deposit verify + redeem, withdrawal issue, webhook delivery.
Appendix A — Onboarding checklist
- FunTokenz provisions sandbox merchant → shares credentials via a secure channel.
- Merchant integrates deposit (redeem) flow; verifies expected error codes.
- Merchant integrates withdrawal (issue) flow; verifies balance debit + code display.
- Merchant registers webhook URL; FunTokenz confirms signature verification.
- Reconciliation smoke test: N deposits + N withdrawals; both sides match on merchant_txn_id.
- Production merchant provisioned; go-live scheduled.
Questions? Contact the FunTokenz integrations team with your merchant_id and a recent X-FTZ-Request-Id.