Tillify Connect for till and platform providers
Connect lets your customers set up Tillify from inside your product, and hands you the API credentials to take payments for them. On a till, show a checkout link as a QR code; online, send the customer to it. Customers pay by bank or card on their phone, and the money goes straight to the business's bank account.
How it works
- You send your user to Tillify.
- They sign in, and pick or create their business and bank account.
- We send them back to you with a one-time Setup token.
- Your server exchanges it for the business's API credentials.
- You create payments with those credentials.
What you'll receive
- A partner key (
pk_…): public, it goes in Connect links. - A partner secret (
ps_…): stays on your servers and authenticates the exchange. - Your registered redirect URIs, which must match exactly.
http://localhostURIs can be registered for development.
Go-live checklist
- 1
Get partner access. Contact us with your company name and the exact redirect URIs you'll use. We send you a partner key and partner secret.
- 2
Add a “Connect Tillify” button that sends your user to the start link (step 1).
- 3
Handle the callback and check
statebefore anything else (step 2). - 4
Exchange the Setup token from your server within 5 minutes and store the client secret encrypted (step 3).
- 5
Wait for payments to be enabled before you offer Tillify at checkout.
- 6
Create payments and show
checkout_urlas a QR code with thetotal(step 4). - 7
Confirm payments by verifying our webhook, or by polling the payment.
- 8
Handle disconnects: a
401means the business revoked your access. Ask them to connect again. - 9
Test with small real payments into your own test business.
- 10
Point your customers at the merchant setup guide, which walks them through Connect from their side.
Send the user to Tillify
Redirect the user's browser to:
GET https://www.tillify.nz/api/v1/connect/start ?partner_key=pk_… &redirect_uri=https://your-app.example/tillify/callback &state=<random value you store against the user's session>
All three parameters are required, and state can be up to 500 characters. redirect_uri must exactly match one you registered. If the link is invalid, we respond 400 with a JSON error and don't redirect.
The user signs in or creates a Tillify account, then confirms their business, or creates one and adds the bank account payments go to. Unless their Stripe account is already connected, Tillify then offers to connect Stripe so customers can also pay by card; the user can skip this. The link is valid for 60 minutes, including any time spent setting up Stripe, and can only be completed by the first person who opens it.
Handle the callback
We send the user back to your redirect_uri:
| Outcome | Query parameters |
|---|---|
| Connected | setup_token=tok_…&state=… |
| User cancelled | error=access_denied&state=… |
Always check that state matches the one you stored for this user before using the setup_token. That's what stops someone completing Connect for their own business and attaching it to your user.
Exchange the Setup token (server to server)
Within 5 minutes:
POST https://www.tillify.nz/api/v1/connect/exchange
X-Partner-Key: pk_…
Authorization: Bearer ps_…
Content-Type: application/json
{
"setup_token": "tok_…",
"webhook_url": "https://your-app.example/webhooks/tillify", // Optional, https only
"external_user_id": "your-user-123" // Optional, for support
}Response:
{
"client_id": "live_…",
"client_secret": "sk_live_…",
"webhook_secret": "whsec_…",
"business": { "id": "…", "name": "Harbour Street Bakery" },
"payments_enabled": true,
"reason": null
}- The
client_secretis only ever returned here. Store it encrypted. - A Setup token works once, and expires after 5 minutes. See errors for what each failure returns.
- If the business connects your product again (for example, you lost the keys), the new exchange returns the same
client_idwith a new secret. The old secret keeps working until that exchange succeeds, then stops. A differentwebhook_urlreplaces the one you sent before. payments_enabledcan befalsestraight after Connect, most often because a bank account entered by hand is being checked (reason: "bank_account_pending", usually about an hour). Other reasons:no_bank_account,business_inactive,suspended_unpaid_fees. PollGET /api/v1/accountto find out when payments are enabled.- The business owner can see and revoke your credentials in Tillify under Integrations. Revoked credentials return
401.
Take payments
Authenticate every call with the business's credentials:
Authorization: Bearer <client_secret> X-Client-Id: <client_id>
Create a payment
POST https://www.tillify.nz/api/v1/payments
Idempotency-Key: <a unique value per payment> // Optional, recommended
{
"amount": 1000, // Cents, before any fee the business passes on
"currency": "NZD",
"external_order_id": "till-42", // Optional, your reference
"description": "Invoice INV-0042", // Optional: what the payment is for
"reference": "INV0042", // Optional: the bank statement reference
"success_url": "https://…", // Optional: leave out on a till
"cancel_url": "https://…", // Optional
"expires_in": 600 // Optional: seconds until it can no longer be paid
}{
"checkout_url": "https://www.tillify.nz/pay/cs_…",
"checkout_session_id": "cs_…",
"status": "pending",
"description": "Invoice INV-0042",
"reference": "INV0042",
"currency": "NZD",
"amount": 1000,
"total": 1035,
"expires_at": "2026-10-05T09:10:00.000Z" // null without expires_in
}- Send an
Idempotency-Keyheader (up to 255 characters) so the call is safe to retry. Repeating a request with the same key returns the payment the first one created instead of making a second. The same key with a different amount, currency,external_order_id,descriptionorreferencereturns409. description(up to 200 characters) is shown to the customer on the checkout page and the payment-confirmed page, and is the description of a card payment in the business's Stripe account.referenceis what a Pay by Bank payment shows in the reference field of the customer's and the business's bank statements. It must be 1 to 12 letters, digits and spaces, starting and ending with a letter or digit; anything else returns400, so what you send is what appears. Without it, the statement showsexternal_order_idwith punctuation removed and cut to 12 characters.expires_inis between 60 seconds and 30 days. Without it a payment never expires. Once it passes, the payment can no longer be started and its status becomescancelled.- On a till, show
checkout_urlas a QR code and leave outsuccess_url. The customer pays on their phone and sees a payment-confirmed page. - Online, redirect the customer to
checkout_url. They're sent tosuccess_urlafter paying. - Display
total, notamount. If the business passes fees on,totalincludes the Pay by Bank fee. A customer who chooses card sees the card total on the checkout page.
Check a payment
GET /api/v1/payments/{checkout_session_id} returns status (pending, completed, failed, cancelled) plus, in cents: payment_method (pay_by_bank or card; null while pending), amount_requested, surcharge (the fee passed on for the method used), amount_paid (null until completed) and amount_refunded (what the business says it has since paid back; 0 if nothing). It also returns description and reference as you sent them, expires_at, refunded_at, and a legacy amount in dollars that you should ignore.
Cancel a payment
POST /api/v1/payments/{checkout_session_id}/cancel stops a payment being paid, for example when a till gives up on a QR code. It returns 200 with the payment once it can no longer be paid. It returns 409 if the payment has already been paid (reason: "already_completed") or the customer is part-way through paying (reason: "payment_in_progress"); in that case keep checking the status, because the payment may still complete.
Webhook
When a payment made with these credentials completes, we POST to your webhook. You only receive payments made with these credentials, never the business's other sales. If your endpoint doesn't answer with a 2xx status within 10 seconds, we send the same body again after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours and 24 hours, then stop. Expect the occasional duplicate and mark each payment paid only once. Check the status to learn about failed or cancelled payments; nothing is sent for those.
{
"event": "payment.completed",
"data": {
"id": "…",
"checkout_session_id": "cs_…",
"external_order_id": "till-42",
"status": "completed",
"payment_method": "pay_by_bank",
"amount_requested": 1000,
"surcharge": 35,
"amount_paid": 1035
},
"created_at": "…"
}The X-Tillify-Signature header is the hex HMAC-SHA256 of the raw body using the webhook_secret. Verify it against the raw body: parsing the JSON and serialising it again can change the bytes. For example, with Node.js and Express:
const crypto = require("crypto");
app.post(
"/webhooks/tillify",
express.raw({ type: "application/json" }),
(req, res) => {
const expected = crypto
.createHmac("sha256", webhookSecretFor(req)) // The business's webhook_secret
.update(req.body) // A Buffer of the raw body
.digest("hex");
const received = req.get("X-Tillify-Signature") ?? "";
const valid =
received.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
if (!valid) return res.status(401).end();
const event = JSON.parse(req.body);
if (event.event === "payment.completed") {
// Mark event.data.external_order_id as paid
} else if (event.event === "payment.refunded") {
// Record event.data.amount_refunded (cents) against the order
} else if (event.event === "payment.failed" || event.event === "payment.cancelled") {
// The payment can no longer be paid: let the customer try again with a new one
}
// Acknowledge every event, including ones you don't recognise
res.status(200).end();
}
);Refunds
Tillify never holds the customer's money, so it can't refund a payment and there's no refund API. A customer who paid by Pay by Bank can ask the business for a refund from their receipt. The business pays it from its own bank account and records the amount in Tillify. When it does, Tillify sends payment.refunded to your webhook: the same body as payment.completed, still with "status": "completed", plus amount_refunded (cents, possibly less than amount_paid) and refunded_at. Only mark a payment paid on payment.completed.
Failed and cancelled payments
When a payment can no longer be paid, Tillify sends payment.failed (the bank or card provider rejected it) or payment.cancelled (the customer backed out, you cancelled it, or it expired) to the webhook you registered at the exchange. The body is the same as payment.completed, with status set to failed or cancelled and amount_paid null. To let the customer try again, create a new payment.
Check the business can take payments
GET /api/v1/account returns { "business": { "id", "name" }, "payments_enabled", "reason" }.
Change your webhook URL
If your own address changes, you don't need to send every business through Connect again. For each connected business, call PUT /api/v1/account/webhook with that business's credentials and { "webhook_url": "https://…" } (https only). It returns 200 with the new webhook_url, and the webhook_secret stays the same. It returns 403 for credentials the business owner created by hand rather than through Connect. A delivery already being retried keeps going to the old URL, so keep that endpoint answering for about 33 hours.
Errors
Every error is JSON with an error message.
| Endpoint | Status | Meaning |
|---|---|---|
| start | 400 | A missing parameter, an unknown partner key, or an unregistered redirect_uri |
| exchange | 400 | Invalid body, e.g. a webhook_url that isn't https |
| exchange | 401 | Wrong partner key or secret |
| exchange | 404 | The Setup token doesn't exist or isn't yours |
| exchange | 410 | The Setup token was already used or has expired, or the owner revoked the credentials first |
| exchange | 429 | Too many failed attempts from your IP: 10 in 15 minutes |
| payments | 401 | Unknown or revoked API credentials |
| payments | 403 | The business can't take payments right now; see reason |
| payments | 409 | The Idempotency-Key was already used for a different payment (reason: idempotency_key_reused) |
| payments | 422 | The business has no bank account (reason: no_bank_account) |
| cancel | 409 | Already paid (reason: already_completed), or the customer is part-way through paying (reason: payment_in_progress) |
Testing
There's no sandbox yet. Test against production with small real payments into your own test business; we refund them on request. The Tillify fees on those payments are billed to that test business like any other, so ask us to waive them.
Questions, or ready to start? Contact us.