Skip to API documentation
PayIt homePayIt

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.

Download the guides

Supported capabilities

CapabilitySupport
Server-to-server mobile-money initiationImplemented
Authenticated link lookup and payment pollingImplemented
Signed merchant webhooksImplemented
Hosted checkout JavaScript SDKImplemented
Direct card initiationNot 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
<script src="https://payit.co.tz/sdk/checkout-v1.js" defer></script>
ModeWhat the customer seesWhen to use it
modalPay button opens checkout over your pageDefault. Use this unless you need another layout.
inlineCheckout sits inside a region of your pagePass a container selector or DOM element.
redirectBrowser goes to the full Pay-IT pageNo 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.

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

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

JavaScript
const checkout = window.PayIt.checkout({
  slug: 'school-fees',
  mode: 'redirect'
});
checkout.open();

Options

OptionRequiredMeaning
slugYesPublished Payment Link slug, up to 80 characters.
modeNomodal (default), inline, or redirect.
containerInline onlyCSS selector or DOM element for the iframe.
onReadyNoEmbedded checkout has initialized.
onProcessingNoA payment attempt entered processing.
onSuccessNoCalled once when checkout reports SUCCEEDED.
onFailureNoCalled once for ATTEMPTS_EXHAUSTED, EXPIRED, or CANCELLED.
onCloseNoCheckout closed. reason is merchant_close, iframe_close, or destroyed.

Checkout instance

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

POST/api/Merchant/v2/payment-links/embed-origins/configure
Replace the origin list
{
  "MerchantId": 42,
  "PaymentLinkId": "plink_972c18ea4ccc288614a6ee56e7550e11",
  "Version": 7,
  "AllowedOrigins": ["https://shop.example.com"]
}
  • Read the current Version from 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 raw postMessage events. 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.

Required headers
X-PayIt-Api-Key: mk_test_YOUR_DISPLAY_ONCE_KEY
X-PayIt-Environment: test
OperationScope
Create paymentmobile_money_payments:create
Poll paymentcheckout_sessions:read
Read link configurationpayment_links:read
Server environment
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

  1. 1Your backend prices the order from trusted product data.
  2. 2Save the merchant reference and a unique idempotency key before calling Pay-IT.
  3. 3Initiate the payment. Persist the returned payment ID. Treat 202 as processing, not paid.
  4. 4Receive signed webhooks. Poll status when a webhook is late or missing.
  5. 5Match payment ID, reference, Payment Link, amount, and currency to your order.
  6. 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.

POST/api/integrations/v1/mobile-money-payments
Request
POST /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
}
FieldRequiredMeaning
payment_link_idYesPublished API-enabled Payment Link owned by this merchant.
amountYesTZS decimal string, at most two fractional digits. JSON numbers are also accepted.
currencyYesMust be TZS.
mobile_numberYesPrefer 255XXXXXXXXX. Local 0XXXXXXXXX is normalized. Max 20 characters.
providerYesCompatibility field such as MPESA. The server infers the MNO from the number.
merchant_referenceNoYour order id, max 120. Not an idempotency key.
payer_purposeNoOnly when the link allows payer purpose. Max 250.
payer_nameNoUnverified display name. Sent on webhooks, not on the poll response. Max 200.
callback_urlNoOptional public HTTPS override of the link webhook. Omit to use the default.

Success is 202 Accepted with raw JSON. There is no data envelope.

202 Accepted
{
  "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

A timeout, 5xx, or missing response is not proof of failure. Retry the same saved key and payload. Do not mint a new key to escape an unknown outcome.
  • Idempotency-Key is 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 processing after 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.

GET/api/integrations/v1/checkout-sessions/{payment_id}
200 OK
{
  "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"
}
StatusTerminalWhat to do
openNoNo active provider attempt. Seen on hosted sessions.
processingNoKeep reconciling. Do not create another charge.
succeededYesReconcile and fulfill once.
attempts_exhaustedYesA new payment needs a new idempotency key.
expiredYesReconcile uncertain outcomes before replacing.
cancelledYesDo 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.

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.

Headers Pay-IT sends
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>
Eventdata.statusNotes
checkout.session.pendingprocessingIncludes payer_name. No failure_category.
checkout.session.succeededsucceededFulfill only after you reconcile this.
checkout.session.failedattempts_exhaustedEvent name is not the session status.
checkout.session.expiredexpiredpayer_name omitted. payer_mode may be null.
webhook.testtestNever fulfill an order from a test event.
checkout.session.succeeded
{
  "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
  }
}
checkout.session.failed
{
  "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"
  }
}
checkout.session.expired
{
  "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
  }
}
webhook.test — do not fulfill
{
  "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

HMAC
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, not created_at. A five-minute freshness window is reasonable.
  • Compare in constant time. The header event id must match id in 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.

Business error
{"error":"The selected amount is not allowed for this payment link."}
Credential error
{"ResponseCode":401,"ResponseMessage":"Invalid API credential."}
HTTPWhen
400Amount, phone, purpose, callback, or idempotency key is invalid.
401Missing, invalid, revoked, or environment-mismatched API key.
403Key is missing the required scope.
404Payment link or checkout session was not found for this merchant.
409Merchant cannot receive payments, or the idempotency key was reused with a different request.
429Rate limit. Honor Retry-After, currently 60 seconds.
503Execution 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

JavaScript
// 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.