Developer Integration API
PayIt developer integration API
Official PayIt merchant API docs for Tanzania: hosted checkout SDK, payment links, M-Pesa and other mobile-money collections, payment status polling, and signed webhooks. API base URL https://app.payit.co.tz/api/integrations/v1.
Supported capabilities
| Capability | Support |
|---|---|
Server-to-server mobile-money initiation | Implemented |
Authenticated link lookup and payment polling | Implemented |
Signed merchant webhooks | Implemented |
Hosted checkout JavaScript SDK | Implemented |
Direct card initiation | Not implemented |
The browser SDK opens hosted checkout. Its UI callbacks are not proof of payment. API keys and webhook secrets belong only on a trusted server.
Web checkout SDK
Use this on a website. Load the script from Pay-IT, then call window.PayIt.checkout with a published Payment Link slug. The customer pays in Pay-IT's hosted page. Your site does not hold an API key to open checkout.
<script src="https://payit.co.tz/sdk/checkout-v1.js" defer></script>| Mode | What the customer sees | When to use it |
|---|---|---|
modal | Pay button opens checkout over your page | Default. Use this unless you need another layout. |
inline | Checkout sits inside a region of your page | Pass a container selector or DOM element. |
redirect | Browser goes to the full Pay-IT page | No iframe and no completion callbacks. |
Modal
Call open() from a click. onSuccess, onFailure, and onProcessing receive payment_id and status. Update the screen from those callbacks, then fulfill the order from a verified webhook.
<script src="https://payit.co.tz/sdk/checkout-v1.js" defer></script>
<button id="pay-now" type="button">Pay now</button>
<script>
window.addEventListener('DOMContentLoaded', function () {
const checkout = window.PayIt.checkout({
slug: 'school-fees',
mode: 'modal',
onSuccess: function (result) {
// Update the screen. Fulfill only after a verified webhook.
console.log(result.payment_id, result.status);
},
onFailure: function (result) {
console.log(result.status);
},
onClose: function (result) {
console.log(result.reason);
}
});
document.getElementById('pay-now').addEventListener('click', function () {
checkout.open();
});
});
</script>Inline
container is a CSS selector or a DOM element. The iframe height stays between 200 and 2,000 pixels.
<div id="payit-checkout"></div>
<script>
window.addEventListener('DOMContentLoaded', function () {
window.PayIt.checkout({
slug: 'school-fees',
mode: 'inline',
container: '#payit-checkout',
onSuccess: function (result) {
console.log(result.payment_id);
}
}).open();
});
</script>Redirect
open() navigates to https://payit.co.tz/pay/school-fees. There are no completion callbacks. Use the Payment Link success and failure pages, or a signed webhook. Pay-IT adds payit_payment_id and payit_status to the return URL. Those values are for navigation, not proof of payment.
const checkout = window.PayIt.checkout({
slug: 'school-fees',
mode: 'redirect'
});
checkout.open();Options
| Option | Required | Meaning |
|---|---|---|
slug | Yes | Published Payment Link slug, up to 80 characters. |
mode | No | modal (default), inline, or redirect. |
container | Inline only | CSS selector or DOM element for the iframe. |
onReady | No | Embedded checkout has initialized. |
onProcessing | No | A payment attempt entered processing. |
onSuccess | No | Called once when checkout reports SUCCEEDED. |
onFailure | No | Called once for ATTEMPTS_EXHAUSTED, EXPIRED, or CANCELLED. |
onClose | No | Checkout closed. reason is merchant_close, iframe_close, or destroyed. |
Checkout instance
| Method | What it does |
|---|---|
open() | Opens modal or inline checkout, or starts the redirect. A second call while open does nothing. |
close() | Closes embedded checkout and reports merchant_close. |
destroy() | Removes embedded checkout and reports destroyed. |
isOpen() | Whether modal or inline checkout is open. |
Allowed website origin
Modal and inline checkout require the exact site origin on the Payment Link, such as https://shop.example.com. That is the scheme, host, and port. It is not a path. Save it with the merchant dashboard session, not the integration API key.
/api/Merchant/v2/payment-links/embed-origins/configure{
"MerchantId": 42,
"PaymentLinkId": "plink_972c18ea4ccc288614a6ee56e7550e11",
"Version": 7,
"AllowedOrigins": ["https://shop.example.com"]
}- Read the current
Versionfrom embed-origins details, then send that version. A stale version is a conflict. - Configure replaces the whole list. An empty list turns embedding off and leaves the standalone pay page available.
- Production origins must be HTTPS and must match
window.location.origin, including a non-default port. - Do not build
/embed/pay/{slug}yourself, and do not listen for rawpostMessageevents. The SDK creates the iframe and checks each message. - Do not put an API key, webhook secret, merchant JWT, or checkout token in the SDK options.
- If the origin is missing or the link cannot be embedded, checkout fails closed. Treat that as checkout unavailable.
Setup and credentials
There is no separate sandbox hostname. Use test with an mk_test_ key and live with an mk_live_ key. A missing or mismatched environment is a 401. Scopes are case-sensitive. Do not send a merchant JWT or public Checkout token.
X-PayIt-Api-Key: mk_test_YOUR_DISPLAY_ONCE_KEY
X-PayIt-Environment: test| Operation | Scope |
|---|---|
Create payment | mobile_money_payments:create |
Poll payment | checkout_sessions:read |
Read link configuration | payment_links:read |
PAYIT_API_BASE=https://app.payit.co.tz
PAYIT_ENVIRONMENT=test
PAYIT_API_KEY=
PAYIT_PAYMENT_LINK_ID=
PAYIT_WEBHOOK_SECRET=
PAYIT_WEBHOOK_PREVIOUS_SECRET=Flow
- 1Your backend prices the order from trusted product data.
- 2Save the merchant reference and a unique idempotency key before calling Pay-IT.
- 3Initiate the payment. Persist the returned payment ID. Treat 202 as processing, not paid.
- 4Receive signed webhooks. Poll status when a webhook is late or missing.
- 5Match payment ID, reference, Payment Link, amount, and currency to your order.
- 6Fulfill exactly once, only after status is succeeded.
1. Initiate a mobile-money payment
Requires mobile_money_payments:create. The payment is reserved and queued for dispatch. A 202 is not proof the customer has paid.
/api/integrations/v1/mobile-money-paymentsPOST /api/integrations/v1/mobile-money-payments
Content-Type: application/json
X-PayIt-Api-Key: mk_test_YOUR_DISPLAY_ONCE_KEY
X-PayIt-Environment: test
Idempotency-Key: order-123-payment-1
{
"payment_link_id": "plink_example",
"amount": "1081.00",
"currency": "TZS",
"mobile_number": "255754123456",
"provider": "MPESA",
"merchant_reference": "ORDER-123",
"payer_purpose": "Order 123",
"payer_name": "Asha Mwinyi",
"callback_url": null
}| Field | Required | Meaning |
|---|---|---|
payment_link_id | Yes | Published API-enabled Payment Link owned by this merchant. |
amount | Yes | TZS decimal string, at most two fractional digits. JSON numbers are also accepted. |
currency | Yes | Must be TZS. |
mobile_number | Yes | Prefer 255XXXXXXXXX. Local 0XXXXXXXXX is normalized. Max 20 characters. |
provider | Yes | Compatibility field such as MPESA. The server infers the MNO from the number. |
merchant_reference | No | Your order id, max 120. Not an idempotency key. |
payer_purpose | No | Only when the link allows payer purpose. Max 250. |
payer_name | No | Unverified display name. Sent on webhooks, not on the poll response. Max 200. |
callback_url | No | Optional public HTTPS override of the link webhook. Omit to use the default. |
Success is 202 Accepted with raw JSON. There is no data envelope.
{
"payment_id": "cs_example",
"payment_link_id": "plink_example",
"merchant_reference": "ORDER-123",
"payer_purpose": "Order 123",
"amount": "1081.00",
"currency": "TZS",
"status": "processing",
"failure_category": null,
"created_at": "2026-10-21T12:00:00Z",
"expires_at": "2026-10-21T12:30:00Z",
"updated_at": "2026-10-21T12:00:00Z"
}Idempotency and uncertain responses
Idempotency-Keyis 1–128 characters and scoped to the API credential.- The same key and the same normalized request replay the stored 202, which can still say
processingafter the payment has finished. Poll for current state. - A changed payload with the same key returns 409.
- Records expire about one day after creation. Do not rely on indefinite replay.
2. Poll payment status
Requires checkout_sessions:read. The path id is the payment_id from initiation. Lookup is scoped to the merchant. Missing or foreign sessions return 404.
/api/integrations/v1/checkout-sessions/{payment_id}{
"payment_id": "cs_example",
"payment_link_id": "plink_example",
"merchant_reference": "ORDER-123",
"payer_purpose": "Order 123",
"amount": "1081.00",
"currency": "TZS",
"status": "succeeded",
"failure_category": null,
"created_at": "2026-10-21T12:00:00Z",
"expires_at": "2026-10-21T12:30:00Z",
"updated_at": "2026-10-21T12:01:00Z"
}| Status | Terminal | What to do |
|---|---|---|
open | No | No active provider attempt. Seen on hosted sessions. |
processing | No | Keep reconciling. Do not create another charge. |
succeeded | Yes | Reconcile and fulfill once. |
attempts_exhausted | Yes | A new payment needs a new idempotency key. |
expired | Yes | Reconcile uncertain outcomes before replacing. |
cancelled | Yes | Do not fulfill. |
failed is an event outcome, not a session status. A terminal provider failure on the direct API is reported as attempts_exhausted. Compare amounts numerically:1081 and 1081.00 are the same value. Polling does not return the payer name, phone, or provider.
3. Read Payment Link configuration
Requires payment_links:read. This is safe configuration, not a guarantee that initiation will succeed.
/api/integrations/v1/payment-links/{payment_link_id}{
"payment_link_id": "plink_example",
"slug": "duka-store",
"title": "Duka store",
"description": null,
"amount_mode": "CUSTOM_ONLY",
"preset_amounts": [],
"minimum_custom_amount": "1000.00",
"maximum_custom_amount": "5000000.00",
"currency": "TZS",
"merchant_reference": null,
"allow_payer_purpose": true,
"allow_api_initiation": true
}amount_mode is PRESETS_ONLY, CUSTOM_ONLY, or PRESETS_AND_CUSTOM. allow_api_initiation must be true before you create a payment.
4. Receive signed webhooks
Pay-IT POSTs to the HTTPS URL you configure. This is not a Pay-IT API route. The path below is an example on your server. Verify with the webhook secret, not the API key.
POST /api/webhooks/payit
Content-Type: application/json; charset=utf-8
PayIT-Event-Id: evt_succeeded
PayIT-Timestamp: 1792584060
PayIT-Signature: v1=<64 lowercase hex characters>| Event | data.status | Notes |
|---|---|---|
checkout.session.pending | processing | Includes payer_name. No failure_category. |
checkout.session.succeeded | succeeded | Fulfill only after you reconcile this. |
checkout.session.failed | attempts_exhausted | Event name is not the session status. |
checkout.session.expired | expired | payer_name omitted. payer_mode may be null. |
webhook.test | test | Never fulfill an order from a test event. |
{
"id": "evt_succeeded",
"type": "checkout.session.succeeded",
"api_version": "2026-08-03",
"created_at": "2026-10-21T12:01:00Z",
"data": {
"payment_link_id": "plink_example",
"slug": "duka-store",
"payment_id": "cs_example",
"amount": "1081.00",
"currency": "TZS",
"merchant_reference": "ORDER-123",
"payer_purpose": "Order 123",
"payer_name": "Asha Mwinyi",
"payer_mode": "GUEST",
"provider": "MPESA",
"initiation_channel": "MERCHANT_API",
"status": "succeeded",
"failure_category": null
}
}{
"id": "evt_failed",
"type": "checkout.session.failed",
"api_version": "2026-08-03",
"created_at": "2026-10-21T12:01:00Z",
"data": {
"payment_link_id": "plink_example",
"slug": "duka-store",
"payment_id": "cs_example",
"amount": "1081.00",
"currency": "TZS",
"merchant_reference": "ORDER-123",
"payer_purpose": "Order 123",
"payer_name": "Asha Mwinyi",
"payer_mode": "GUEST",
"provider": "MPESA",
"initiation_channel": "MERCHANT_API",
"status": "attempts_exhausted",
"failure_category": "DECLINED"
}
}{
"id": "evt_expired",
"type": "checkout.session.expired",
"api_version": "2026-08-03",
"created_at": "2026-10-21T12:01:00Z",
"data": {
"payment_link_id": "plink_example",
"slug": "duka-store",
"payment_id": "cs_example",
"amount": "1081.00",
"currency": "TZS",
"merchant_reference": "ORDER-123",
"payer_purpose": "Order 123",
"payer_mode": null,
"provider": null,
"initiation_channel": "MERCHANT_API",
"status": "expired",
"failure_category": null
}
}{
"id": "evt_test",
"type": "webhook.test",
"api_version": "2026-08-03",
"created_at": "2026-10-21T12:01:00Z",
"data": {
"payment_id": "cs_example",
"amount": "1081.00",
"currency": "TZS",
"payer_name": "Asha Mwinyi",
"initiation_channel": "MERCHANT_API",
"status": "test"
}
}Signature verification
v1=lowercase_hex(HMAC_SHA256(webhook_secret, timestamp + "." + raw_body))- Sign the exact raw UTF-8 body. Do not re-serialize JSON first.
- Use
PayIT-Timestamp, notcreated_at. A five-minute freshness window is reasonable. - Compare in constant time. The header event id must match
idin the body. - Return 2xx only after you have stored the event. A 2xx means received, not paid.
- Retries are roughly immediate, 30s, 2m, 10m, 1h, 6h, 24h, and 48h. Keep the previous secret for the full retry window, about 80 hours.
Errors and retries
Business rejections use {"error":"..."}. API-key failures use {"ResponseCode":401,"ResponseMessage":"..."}. Do not assume one envelope.
{"error":"The selected amount is not allowed for this payment link."}{"ResponseCode":401,"ResponseMessage":"Invalid API credential."}| HTTP | When |
|---|---|
400 | Amount, phone, purpose, callback, or idempotency key is invalid. |
401 | Missing, invalid, revoked, or environment-mismatched API key. |
403 | Key is missing the required scope. |
404 | Payment link or checkout session was not found for this merchant. |
409 | Merchant cannot receive payments, or the idempotency key was reused with a different request. |
429 | Rate limit. Honor Retry-After, currently 60 seconds. |
503 | Execution is not configured, or the service is temporarily unavailable. |
Fix 400, 401, 403, and 404 instead of retrying them. On 429, wait. On timeout or 5xx, keep the same order and key.
Minimal server example
// Server code only. Persist this key and exact request before sending.
const response = await fetch(
`${process.env.PAYIT_API_BASE}/api/integrations/v1/mobile-money-payments`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"X-PayIt-Api-Key": process.env.PAYIT_API_KEY,
"X-PayIt-Environment": process.env.PAYIT_ENVIRONMENT,
"Idempotency-Key": order.savedPaymentKey
},
body: JSON.stringify(order.savedPaymentRequest),
signal: AbortSignal.timeout(12000)
}
);
const result = await response.json();
if (response.status !== 202) {
throw new Error(result.error ?? result.ResponseMessage ?? "Payment not accepted");
}
// Persist payment_id before returning. HTTP 202 does not mark the order paid.A timeout must enter a reconciliation state, not a loop that creates a new payment. Log correlation ids only. Never log secrets or full phone numbers.