# Authentication (/docs/authentication) Authentication ## API keys [#api-keys] All requests to the Volley API must be authenticated using an API key which you can generate yourself in the [Volley dashboard](https://dashboard.volley.nz/settings/apikeys). Volley uses two types of API key: **test** and **live**: * **Test keys:** limit any payments to Volley’s test bank account only. Test keys are appropriate for use in staging and development environments. * **Live keys:** allow your customers to pay into to your bank account with their real banking app. Use these only in production. ```sh // Example test key authkey_test_VeBI3n7J:MdLftNqgKsPK9NOgo418CDvZ // Example live key authkey_live_VeBI3n7J:MdLftNqgKsPK9NOgo418CDvZ ``` API keys are secret credentials that grant access to your account's resources. They should only be used in a secure server-side environment, never exposed or embedded in client-side code or committed to public repositories. ## Authenticating requests [#authenticating-requests] Include your key as a Bearer token in the `Authorization` header of each request. ```http Authorization: Bearer ${VOLLEY_API_KEY} ``` ## Revoking a key [#revoking-a-key] You can revoke keys at any time in the [Volley Dashboard](https://dashboard.volley.nz/settings/apikeys), revocation will be effective immediately. # Data requests (/docs/data-requests) Data request flow The Data Requests API is currently in Beta while we finalise the API design and test across banks. If you have a use case in mind, [get in touch](mailto://support@volley.nz) and we can work with you on early access. Data requests let you ask a customer to securely share information from their bank account with you, without you ever needing to see or store their banking credentials. Your customer approves exactly what they want to share directly in their bank app, and Volley delivers the data to you via our API. ## How it works [#how-it-works] 1. **Create a data request.** Call the Volley API to create a request for the type of data you need and whether the request is one-off or ongoing. 2. **Present it to your customer.** Redirect them to the hosted consent page, or use the embed or mobile SDK to keep them in your app. 3. **They approve.** The customer selects their bank, is taken to their bank app, and approves or declines the data sharing request. 4. **Pull the data.** Once approved, Volley fetches the data from the customer's bank and makes it available via API. For ongoing requests, you can call the API again at any time to get updated data. ## Request types [#request-types] Volley supports two types of data request: **Account verification** and **Transaction history**. ### Account verification [#account-verification] Account verification requests let you retrieve a customer's bank account details and identity information. This includes the account holder name, account number, and contact details held by their bank. Account verification is used for confirming account ownership before making a payout, or verifying a customer's identity as part of onboarding. ### Transaction history & balances [#transaction-history--balances] Transaction history requests allow you to read transactions from a connected bank account for a given date range, along with the balance held on that account at the time of the fetch. Transaction data is useful for affordability checks, income verification, expense categorisation, or aggregating a user's bank accounts within your app. ## One-off and ongoing access [#one-off-and-ongoing-access] Data requests can be either one-off or ongoing: A **one-off** request fetches data once at the point of consent. Volley will retrieve all data relevant to the request once, and then the connection to the bank is ended. You are able to retrieve data fetched from a one-off consent via the Volley API for up to 14 days afterward. One off requests are the best option for point-in-time checks like identity verification or a single affordability assessment. An **ongoing** request keeps the consent active for a defined period, allowing you to fetch updated data at any time without asking the customer to re-approve. This is suited to use cases like continuous transaction monitoring, recurring affordability checks, or keeping account details in sync. The customer can revoke an ongoing consent at any time through their bank app or the Volley dashboard at [my.volley.nz](https://my.volley.nz). # Errors (/docs/errors) All application error responses include an error code, a human-readable message, and optional parameters providing additional context. ```json { "code": "bad_request.invalid_param.amount", "message": "Invalid format for amount (valid: '10.00 NZD')", "params": {} } ``` | Field | Type | Description | | --------- | -------- | ----------------------------------------------------------- | | `code` | `string` | A machine-readable error code identifying the type of error | | `message` | `string` | A human-readable description of the error | | `params` | `object` | Additional parameters related to the error (optional) | # Hosted page (/docs/hosted-page) Hosted checkout Every Volley payment request automatically comes with a hosted payment page. When you create a request, Volley returns a `url` which you can redirect your customer to and Volley handles the rest: bank selection, redirect to the bank app, approval, and redirecting the customer back to you. This is the easiest way to get started. ### Benefits [#benefits] * No frontend integration required: Volley provides the complete payment UI * Automatic device detection: Displays QR code for desktop users or direct redirect for mobile users * Built-in failure handling: Payment failures and retry logic managed by Volley ### How it works [#how-it-works] 1. **Create a request.** Volley returns a payment request with a hosted `url`. 2. **Redirect your customer.** Send them to the `url` - in the browser, a new tab, or a webview. 3. **They approve.** The hosted page walks them through selecting their bank and approving the payment in their banking app. 4. **They come back.** Volley returns the customer to you via the `success_redirect_url` or `failure_redirect_url` parameters once the payment reaches a terminal state. ### Redirect URLs [#redirect-urls] Set `success_redirect_url` and `failure_redirect_url` when you [create a request](/reference/payment-requests). The host of each URL must be added as an [allowed domain](https://dashboard.volley.nz/settings/domains) in the Volley Dashboard. When Volley redirects the customer back, it appends the `payment_id` and your `merchant_identifier` to the URL. So a `success_redirect_url` of `https://example.com/success` receives the customer at: ``` https://example.com/success?merchant_identifier=df28616f3ab2b640&payment_id=payment_g5wa1X8YbJPqX9x3eMftC ``` Note: Don't treat the redirect as proof of payment - use the `payment_id` to check the payment status via the API, or rely on [webhooks](/docs/webhooks) as the source of truth. ### Desktop vs. mobile flows [#desktop-vs-mobile-flows] Wherever possible, Volley uses app-based approval for open banking payments. The customer is redirected to their bank app, where they confirm the payment using their device biometrics (fingerprint or Face ID). This is both more secure and easier for customers than entering a username and password into their bank's internet banking website. When opening the hosted payment page on a desktop device, Volley will display a QR code for a user to scan to complete the payment in their bank app. Once they've approved , we'll automatically redirect the desktop session back to your website via the redirect URLs. Scan to pay # Overview (/docs) Get started ### Volley Developer APIs [#volley-developer-apis] Welcome to the Volley developer documentation. Our APIs allow you to connect your business or app to all of NZ's major banks to accept payments from your customer's directly from their bank app, with no need for cards. Our APIs are REST-based and designed to be easy to integrate using any server-side language or framework. Our engineers are also on hand to offer integration advice and support, see below. If you're looking for a low-code or no-code option, payment requests can be created on our Dashboard and you can integrate with minimal effort with our fully [hosted payment links](/docs/hosted-page). ### Dashboard [#dashboard] Before you can get started, you'll need to use the [Volley Dashboard](https://dashboard.volley.nz) to create API keys, setup domains for your applications, or subscribe to webhooks. [Get in touch](mailto://support@volley.nz) if you're unsure about how to access this. ### Support [#support] We're also here to support! Please get in touch with any questions or clarifications needed, you can reach us by email at [support@volley.nz](mailto://support@volley.nz). ### Explore [#explore] # About open banking (/docs/open-banking) Open banking In New Zealand, open banking is provided by your bank as part of the the Consumer Data Right (CDR), meaning it's a regulated part of our national payments landscape. Currently, all major banks are required to provide secure APIs to accredited data requestors for payment initiation and data sharing. New Zealand's open banking journey has closely followed that of the United Kingdom, where open banking was established in 2018 as part of the European Payment Services Directive (PSD2). Open banking has been in development in New Zealand since 2019 and became regulated by MBIE under the CDR in December 2025. You can find out more about this and view other participants on MBIE's website at the link below. [https://www.mbie.govt.nz/business-and-employment/business/consumer-data-right](https://www.mbie.govt.nz/business-and-employment/business/consumer-data-right) ### Benefits [#benefits] * **No cards.** Payments come straight from the customer's bank account, so there's no need to enter card details and no dependency on card networks. * **Lower fees.** Payments are cheaper to accept (Volley charges a flat rate of $0.35, regardless of the transaction amount) and settle directly to your account, usually within an hour. * **Bank security.** Customers authorise every payment (or recurring payment) in their own banking app, using their bank's own security measures. * **A simpler alternative to direct debit.** Customers can securely setup recurring payments in a few taps in their banking app, getting rid of manual authority forms. Open banking payments do not have dishonour fees as with direct debit. ### How does it work? [#how-does-it-work] Volley uses open banking to let your customers pay you directly from their bank, without ever needing to enter credit or debit card. With most banks, payments are approved in a few taps in their banking app, making it safe, easy, and familiar for payers. Payments made with Volley are confirmed instantly and irreversible once confirmed, making it suitable for a wide range of online or in-person points of sale. Payments are executed directly to your account and settlement takes place over the interbank payments network, so funds typically arrive in 1 hour, on weekdays and weekends, 365 days per year. Payments made after 11pm are settled at 9am the following day. Open banking involves a different integration for every bank, with different APIs, consent flows, and limits. Volley simplifies all of that into a single object: the [payment request](/docs/payment-requests). You create one request, and Volley handles the bank connections, consent, and settlement for you. ### The authorisation flow [#the-authorisation-flow] Open banking supports two types of authorisation flows: redirect and decoupled. * **Redirect:** payers are redirected straight to their bank app (in some cases internet banking website) on their mobile device, and redirected back to Volley after approving the payment. * **Decoupled:** payers enter an identifier (e.g. their phone number) and a notification is sent to their bank app to approve the payment. This is also known in the industry as Client Initiated Backchannel Authorisation, or CIBA. Volley determines the best possible flow to present to customers based on their bank and device. We strongly prefer to use app-based redirect flows as this offers the best security for payers. In most cases, customers will require their phone and their bank app to approve a payment. For customers on desktops, Volley presents a QR code that lets them seamlessly complete the payment on their phone. Currently, BNZ does not support app-based redirection. Volley uses a decoupled flow with phone number for BNZ payers. ### Bank coverage and limitations [#bank-coverage-and-limitations] Volley integrates with all major banks in New Zealand, currently ANZ, ASB, BNZ, Kiwibank, and Westpac. All features, including one-off and recurring payments, are fully supported by the banks we integrate with. #### Payment limits [#payment-limits] Banks have limits that cap how much an individual can transfer in one payment, or across all payments over a day. For Open banking, payments limits are mostly aligned with each bank's own limits for manual transfers within their app or online banking channels. The table below shows current limits effective for Volley payments. | Bank | Per payment limit | Daily payments limit | | -------- | -------------------- | --------------------- | | ANZ | $10,000 | No specific limit | | ASB | $100,000 (effective) | $100,000\* | | BNZ | $50,000 (effective) | $50,000 | | Kiwibank | No specific limit | No specific limit | | Westpac | $30,000 (effective) | $30,000 | \***ASB:** Recurring payments with ASB are exempt from the daily limit but are capped at $10,000 per transaction. # Payment requests (/docs/payment-requests) Payment request flow The core model of Volley's API is the **payment request**. Payment requests are built on a flexible model and allow you to take many kinds of payments, for instance you can: * Request a fixed amount from one person for a specific transaction, e.g. an invoice, a checkout purchase, or an in-person service. * Let a customer pay whatever they choose with [flexible amounts](#flexible-amounts). * Create a [re-usable link](#one-off-and-reusable-requests) or QR code that many people can pay or donate to. * Collect a customer's [contact details](#collecting-customer-details) or shipping address as an optional or required step during payment. * Allow a customer to opt-in to multiple payments over time using [recurring payment requests](/docs/recurring-payments). * Add a surcharge to cover the cost of payment processing. For all types of payment request, your customer will approve the payment quickly and securely through their bank app or internet banking. Our API makes it simple to define the bank account you want to be paid to, the amount to pay, reference details for the bank statement, and a message to help customers see what it is they're paying for. ### How it works [#how-it-works] 1. **Create a request.** Call the Volley API with the bank account that will receive funds, the amount, and an optional reference. Volley returns a payment request with an unique `id` and `url`. 2. **Present it to your customer.** Use one of our UI options (QR code, hosted, embedded) to share or redirect your customer to the request, 3. **Customer approves.** They select their bank and are redirected to their bank app to and approve or decline the payment. 4. **Get the result.** Volley will redirect a customer back to your app on completion, or you can poll or subscribe to [webhooks](/docs/webhooks) to get notified once payment is made. ### Creating your first request [#creating-your-first-request] Send a `POST` to `/v1/requests` with your [API key](/docs/authentication) included as a Bearer Token: ```bash title="POST https://api.volley.nz/v1/requests" curl -X POST https://api.volley.nz/v1/requests \ -H "Authorization: Bearer $VOLLEY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "bank_account_id": "bankaccount_xxx", "amount": "50.00 NZD", "message": "Invoice #123", "type": "single", "reference": "REF123", "merchant_identifier": "37436c2350b34586bde2c13128920b24" }' ``` | Field | Description | | ----------------- | ------------------------------------------------------------- | | `bank_account_id` | The account which you'll be paid into. | | `amount` | Fixed amount up to a maximum of $5000.00, e.g. `"10.00 NZD"`. | | `message` | A short message to describe the payment (max 60 characters). | | `reference` | Reference for bank statement, see PCR Fields. | | `type` | "single" for one payment, "multi" for multiple payments. | See the [API Reference](/reference/payment-requests) for the full list of parameters. ```json title="200 https://api.volley.nz/v1/requests" { "request": { "id": "request_Dr9...", "url": "https://app.volley.nz/pay/Dr9...", "status": "new", "type": "single", "message": "Invoice #123", "amount": { "value": "50.00 NZD", "formatted": "$50.00" }, "bank_account_id": "bankaccount_5az...", "user_id": "user_PB3CbjbM8xdCNALUlC5JQ", "merchant_identifier": "37436c2350b34586bde2c13128920b24", "logo_image_url": "https://storage.googleapis.com/volley-user-images-.../upload_OtLY5EsbyCnMBJc9wgOmL", "pcr": { "particulars": "", "code": "", "reference": "REF123" }, "min_payment_amount": 50, "max_payment_amount": 1000000, "is_test_mode": false, "is_donation": false, "add_surcharge": false, "surcharge_optional": false, "allow_dynamic_amount": false, "allow_marketing_opt_in": false, "collect_customer_phone": false, "require_customer_phone": false, "require_customer_basic_info": false, "require_customer_address": false, "created_at": "2026-08-15T21:59:05Z", "updated_at": "2026-08-15T21:59:05Z" } } ``` Volley responds with the created request wrapped in a `request` object. The `id` is what you pass to the [web embed](/docs/web-embed) or SDK, and `url` is the hosted payment page: | Field | Description | | --------------------------------------------------- | ------------------------------------------------------------------ | | `request.id` | Pass this to the embed SDK as `requestId`. | | `request.url` | Hosted payment page for the request. | | `request.status` | See [request status and lifecycle](#request-status-and-lifecycle). | | `request.amount.value` | Normalised amount, e.g. `"50.00 NZD"`. | | `request.amount.formatted` | Display string, e.g. `"$50.00"`. | | `request.pcr` | Particulars, code and reference for the bank statement. | | `request.min_payment_amount` / `max_payment_amount` | Payment bounds in cents. | Scan to pay on desktop and mobile ### Request status and lifecycle [#request-status-and-lifecycle] | Status | Description | | --------- | --------------------------------------------------------------------------------------------------- | | `active` | The request is live and can accept payments. | | `paid` | The request has received all expected payments (e.g. a single request with one successful payment). | | `expired` | The request has passed its expiry date and can no longer accept payments. | | `deleted` | The request was deleted. No new payments are allowed. | Request expiry can be set with expiresIn parameter (e.g. `"expires_in": "10m"`) when creating a request, and Volley will automatically disable further payments after this time. We recommend setting expiry to a minimum that's reasonable for your use case to avoid the chance of receiving payments beyond what is expected, e.g. 15 minutes for an e-commerce checkout, or 1 month for a request attached to an invoice. ### One-off and reusable requests [#one-off-and-reusable-requests] Payment requests can be either one-off, where only one payment is accepted, or reusable, where the same request can be paid any number of times by different customers. Reusable requests are perfect for things like charity donations, fundraising, collecting fees or subs, or any other scenario where multiple customers pay the same amount for the same product or service. To setup a reusable payment, include `"type": "multi"` in your request: ```json title="POST https://api.volley.nz/v1/requests" { "bank_account_id": "bankaccount_ZYm2YeJXZ8Vzx3jjMLyp6", "message": "Ticket", "type": "multi", "amount": "10.00 NZD" } ``` Multi requests do not transition to the "paid" status after the first payment is made, instead remaining active so additional customers can use it to pay. Each payment against a request is tracked as its own unique payment ID and it's own status. ### Flexible amounts [#flexible-amounts] Custom amounts Volley requests can be for a fixed amount or you can let the payer choose how much they'll pay. This is ideal for donation use cases, where multiple donors can use the same QR code or link but each choose their own amount to donate on their device. To setup a dynamic amount request you can set `"allow_dynamic_amount": true` on a request: ```json title="POST https://api.volley.nz/v1/requests" { "bank_account_id": "bankaccount_ZYm2YeJXZ8Vzx3jjMLyp6", "message": "Ticket", "type": "multi", "allow_dynamic_amount": true, "suggested_amounts": "10,20,50,100,200" } ``` Dynamic amount requests can also have suggested amounts to make it quick to donate. You can include these by passing a comma-separated list of amounts as a string, e.g. `suggested_amounts: "10,20,50,100,200"`. ### Collecting customer details [#collecting-customer-details] Customer details Volley requests can take contact or address details from your customer before payment is made. This is useful for event ticketing and charitable donations where you need to know where to send a ticket or a receipt. The options available are granular to let you determine exactly what is required for payment, see all available options below. ```json title="POST https://api.volley.nz/v1/requests" { "bank_account_id": "bankaccount_ZYm2YeJXZ8Vzx3jjMLyp6", "message": "Ticket", "amount": "20.00 NZD", "type": "multi", "require_customer_basic_info": true, "collect_customer_phone": true, "require_customer_phone": false, "require_customer_address": true, "allow_marketing_opt_in": true } ``` | Field | Description | | ----------------------------- | -------------------------------------------------------------------- | | `require_customer_basic_info` | Require the customer's email address and name before payment. | | `collect_customer_phone` | Show a phone number field so the customer can optionally provide it. | | `require_customer_phone` | Require the customer's phone number before payment. | | `require_customer_address` | Require the customer's postal address before payment. | | `allow_marketing_opt_in` | Show a marketing opt-in checkbox before payment. | Volley can also be configured to send tax-credit donation requests to payers automatically on your behalf. [Get in touch](mailto://support@volley.nz) if you'd like to use this feature. ### Particulars, code and reference [#particulars-code-and-reference] Particulars The New Zealand banking system has three text fields used for providing payment references and other identifiers: particulars, code, and reference, collectively referred to as the "PCR" fields. These are included on the bank statement lines for both the payee and the payer bank accounts when a payment is made to help with customer identification and reconciliation. Volley uses the particulars and code fields, and you can optionally use the reference field when creating a payment request. The particulars and code can be made available for your application if needed, please [get in touch](mailto://support@volley.nz) if you require this for your use case. | Field | Description | | ----------- | ------------------------------------------------------------------------- | | Reference | Free text, you can provide anything matching `[a-zA-Z0-9-]{0,12}`. | | Particulars | A unique payment identifier set by Volley. In the form `V-XXXXXX`. | | Code | Always set to "Volley" to help payers identify how this payment was made. | ### Test mode [#test-mode] Volley provides a fully-featured test mode which allows you to create a realistic test environment for your app, using real bank apps to simulate a payment. When using test mode, all payments are overriden to $0.01 and paid to a dedicated test bank account owned by Volley rather than to your business account. We'll configure this test bank account for you during onboarding. You can create test mode payment requests by using a test environment [API key](/docs/authentication). Test mode requests can have payments made by any of our supported banks and a "sandbox bank" labelled on the request page as “API Centre”. The sandbox bank lets you quickly the payment flow without needing to use a real bank app. You can access the sandbox with the following credentials: * **Username:** user01 * **Password:** Any password. #### Test amounts [#test-amounts] When in test mode, you can create a payment request for specific "magic" amounts to simulate different failure scenarios. | Amount | Test scenario | | ------ | ----------------------------------------------------------------------------------------------------- | | 67.80 | Any payment made for this request will fail at the callback stage. Payment status is set to `failed`. | # Rate limits (/docs/rate-limits) The Volley API may rate limit requests with a 429 status code. If you receive this code, please retry any current action at a slower rate. We have configured our own rate limits to allow for most use cases, and we will check with you to make sure rate limits are appropriate for your usage during onboarding. # Recurring payments (/docs/recurring-payments) Recurring payment Recurring payments let you collect multiple payments from a customer all from a single authorisation in their bank. Each recurring payment stores a customer's payment authorisation so you can charge them again later without re-approval (within pre-agreed limits). Recurring payments via open banking are a modern alternative to having a credit/debit on file or setting up a direct debit authorisation. You decide when to charge and how much, so it's ideal when the amount or timing varies, like with usage-based billing, pay-as-you-go top-ups, repeat checkouts, or any scenario where you need to pull funds on your own schedule. If you're looking to collect a fixed amount on a regular schedule without managing the billing cycle yourself, use our [Subscriptions API](/docs/subscriptions) instead. Recurring payment setup works just like a one-off payment, where you create a payment request and the customer authorises it at their bank. The difference is that instead of approving a single charge, the customer approves ongoing payments to you within certain limits (e.g. "up to $50 per month"). Once authorised, you can charge any amount within those limits via our API with no further customer interaction. ## Recurring payment types [#recurring-payment-types] Volley currently supports two types of recurring payment: * **Merchant-initiated (explained in this guide)** — your server stores the consent token and calls the Volley API to execute payments whenever you need to. The customer doesn't need to be present, making this ideal for subscriptions, scheduled billing, or usage-based charges. * **Customer-initiated** — the customer approves each payment themselves using biometrics, after previously connecting their bank. No server-side integration is required on your end. ## Create a recurring payment request [#create-a-recurring-payment-request] Create a recurring request by calling [`POST /requests`](/reference/payment-requests/createRequest) including the `recurring_options` block in place of a fixed `amount`. ```json title="POST https://api.volley.nz/v1/requests" { "bank_account_id": "bankaccount_ZYm2YeJXZ8Vzx3jjMLyp6", "message": "Pro plan", "type": "single", "recurring_options": { "period": "monthly", "max_per_payment": "150.00 NZD", "max_per_period": "500.00 NZD" }, "cust_info": { "email": "jack@volley.nz" } } ``` ```json title="Response" { "request": { "id": "request_qTVSdEszuE9jfpTQaJ3j7", // ... "status": "active", "url": "https://app.volley.nz/pay/qTVSdEszuE9jfpTQaJ3j7", "recurring_options": { "period": "monthly", "max_per_payment": "150.00 NZD", "max_per_period": "500.00 NZD", "end_date": "2027-01-01T00:00:00Z" } } } ``` As with one-off payments, you can redirect the customer to the returned `url` to have them authorise it via the [hosted page](/docs/hosted-page). Recurring payment requests are currently only supported for a single customer approval, i.e. `"type": "single"`. ### Recurring options [#recurring-options] | Field | Required | Description | | ----------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `period` | Required | The window over which period limits apply to, currently supports `daily`, `weekly`, or `monthly`. | | `max_per_period` | Required | The maximum amount that can be charged during the period. A `max_per_period` of `"50.00 NZD"` with a `monthly` period means no more than $50 per month. Set this to a limit your customer would recognise as safe and reasonable for the payments you typically make. | | `max_per_payment` | Optional | The maximum charge for any single payment. For a fixed subscription (e.g. $19 a month), set this as assurance of what the customer will pay. | | `total_count` | Optional | A cap on the total number of payments, useful for instalment payments, e.g. 6 payments of $300. | | `total_amount` | Optional | A cap on the total value of all payments. | | `end_date` | Optional | The date after which no further payments can be taken. | Web SDK recurring ### Customer info [#customer-info] When setting up a recurring payment, you must also pass customer identification details to Volley. At minimum, a verified email address is required. Volley uses this email to give your customer access to the consent management dashboard and to send any regulatory communications about their recurring payment. If you already handle these communications in your own system, you can opt out of Volley sending them. Get in touch to discuss this option. ### Receive a consent token [#receive-a-consent-token] When a customer approves your recurring request, Volley sends a `recurring_payment.authorised` [webhook](/docs/webhooks) containing a **token**. Store it against the customer in your database — it's how you charge them, and it must be included in every payment you execute. ```json title="POST to your webhook URL" { "type": "recurring_payment.authorised", "data": { "id": "request_nHO4BFIEpuGcyHlUlVGa1", "status": "active", "merchant_identifier": "e098a9a01f4249e9948cf5ab701d1480", "token": "U5KFvqL0wMHJYpRZxzPgT:Bnv-rXSahn3wzC1QGAxYjBfdr8brm22tT5KqGmtgu70" } } ``` **Keep this token secret** as it can be used (when paired with your API key) to move funds from your customer to your bank account. Store it securely on your server and never expose it in client-side code. We recommend encrypting the token for storage in your database with the encryption key stored seperately in an appropriate secret manager or specialised key manager (e.g. Secret Manager or KMS on GCP or AWS). ## Execute a payment [#execute-a-payment] To charge the customer, call the `POST /v1/payments`, providing the token and an amount. Both Volley and the customer's bank will check that this payment will not exceeed the pre-agreed limits before the payment is executed. Payments are execute synchronously, the result will be returned in the response, and also triggers a `payment.status_updated` webhook. ```json title="POST https://api.volley.nz/v1/payments" { "token": "U5KFvqL0wMHJYpRZxzPgT:Bnv-rXSahn3wzC1QGAxYjBfdr8brm22tT5KqGmtgu70", "amount": "49.99 NZD", "idempotency_key": "02aabe16d1:inv_456:4999NZD", "reference": "Invoice #456", "merchant_identifier": "02aabe16d1554a9abf288947bc0a04f2" } ``` `reference` and `merchant_identifier` are optional. ```json title="Response" { "payment": { "id": "payment_E0fhAQcZiIHUdcV2hJ1uR", "request_id": "request_3H164LUImn6POa3JaZcIn", "provider": "kiwibank", "amount": { "formatted": "$49.99", "value": "49.99 NZD" }, "pcr": { "code": "Volley", "particulars": "V-WZ43RF", "reference": "Invoice #456" }, "is_test_mode": false, "status": "successful", "completed_at": "2026-05-19T02:09:44Z", "created_at": "2026-05-19T02:09:42Z", "updated_at": "2026-05-19T02:09:46Z" } } ``` ### Idempotency [#idempotency] You must provide an idempotency key to avoid duplicate payments. If Volley receives a retried request with the same key, it will returns the original result instead of creating a second charge. Use a key that's naturally derivable from your own records (e.g. your invoice ID or order ID). This way, if a request fails mid-flight and you retry, you'll produce the same key without having to track it separately. You can check the returned HTTP status to confirm if a payment was successful performed on a retry: `201` means a new payment was created, `200` means an earlier created payment was returned. ## Revoke a recurring payment consent [#revoke-a-recurring-payment-consent] A recurring payment consent can be cancelled in three ways: 1. **By the customer through their bank**, directly in their bank app. 2. **By the customer through Volley**, using the consent management dashboard at [my.volley.nz](https://my.volley.nz). 3. **By you, on behalf of the customer**, for example, when a customer cancels through your UI or closes their account. Use the revoke consent endpoint to cancel the consent via the API. By default, Volley handles consent management for you - your customers can cancel recurring payments anytime through their bank app or the Volley dashboard at my.volley.nz, so you don't need to build any cancellation UI yourself. Of course, if you'd like to also offer this in your own app, you can do so and use the API to revoke consents on the customer's behalf. ### Revoke via the API [#revoke-via-the-api] Call `PUT /v1/recurring-consents/{id}/revoke`. No other parameters aside from the recurring payment ID are required. Once it returns, no further payments can be executed against the consent. Any stored consent token on your side can be safely deleted. ### Handle customer revocations [#handle-customer-revocations] Customers manage their consents through My Volley at [my.volley.nz](https://my.volley.nz). Customers can also revoke a consent themselves directly with their bank. After logging in with their email address (matching what you registered when creating a recurring consent) they can see all of their recurring payments. Today the only option is to revoke a consent; but we are planning improvements here in the near future - for example, letting customers move their consent to another bank. There are different failure modes to be aware of based on where a customer revoked their consent: * If revoked through Volley, you'll receive a `recurring_payment.revoked` webhook. Register for it in the dashboard to keep your records in sync. Any attempts to execute a payment for a revoked consent will fail. * If revoked through their bank, no webhook is sent but the next attempt to execute a payment will fail. ## Handle payment failures [#handle-payment-failures] Beyond revoked consents, a payment can fail because the bank rejected it - for example, there was insufficient balance or the payment failed the bank's own fraud checks. Handle failed executions gracefully and surface them to the customer where appropriate. # Request lifecycle (/docs/request-lifecycle) Coming soon. # Settlement (/docs/settlement) Coming soon. # Showcase (/docs/showcase) This page exists to see how things look. Everything below is a Fumadocs component you can drop into any `.mdx` file. ## Callouts [#callouts] Good for notes, warnings, and gotchas. Test keys start with `sktest_`. Live keys start with `sklive_`. Don't mix them up. Point the playground at the sandbox server before sending real requests. Don't ship your secret key in the iOS binary. Proxy calls through your backend. ## Tabs [#tabs] Great for showing the same call in multiple languages. ```bash curl -X POST "https://api.volley.nz/v1/requests" \ -H "Authorization: Bearer sktest_..." \ -H "Content-Type: application/json" \ -d '{ "bank_account_id": "ba_123", "amount": "10.00 NZD", "message": "Thanks!" }' ``` ```js const res = await fetch("https://api.volley.nz/v1/requests", { method: "POST", headers: { Authorization: `Bearer ${process.env.VOLLEY_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ bank_account_id: "ba_123", amount: "10.00 NZD", message: "Thanks!", }), }); ``` ```swift VolleySDK.pay(requestID: "request_8Gbn", amount: "10.00 NZD") { result in // handle result } ``` ## Steps [#steps] Perfect for the payment flow. ### Create a request [#create-a-request] Call `POST /requests` with an amount and message. You get back a `request_id`. ### Start consent [#start-consent] Call `POST /requests/{id}/accept` with a provider. You get a redirect URL to the payer's bank. ### Complete from callback [#complete-from-callback] The bank redirects back with a code. Exchange it at `POST /payment/callback`. ## Cards [#cards] Navigation tiles. Fetch a request, start consent, complete it. Register a device key and execute device-signed payments. ## Accordions [#accordions] For FAQs and optional detail. A request is the ask ("pay me $10"). A payment is a single attempt to fulfil it. One request can have multiple payments. ANZ, ASB, BNZ, Westpac, and Kiwibank via direct API integrations. ## Tables [#tables] Plain Markdown tables work too. | Status | Meaning | | ------------------ | ------------------------------ | | `pending` | Created, not yet seen by payer | | `awaiting-consent` | Payer is at their bank | | `successful` | Funds confirmed | | `failed` | Something went wrong | ## Inline formatting [#inline-formatting] You get **bold**, *italic*, `inline code`, and [links](https://volley.nz) for free. > Blockquotes render too, if you want to pull something out. # Subscriptions (/docs/subscriptions) Recurring payment Subscriptions in Volley let you collect an agreed amount from a customer automatically, with Volley taking care of scheduling charges and handling retries for you. Subscriptions are designed for any case where your customer wants to pay a fixed, repeating amount - perfect for subscriptions (its in the name!), memberships, or instalments. Subscriptions are for fixed payments on a repeating schedule, like "$19.99 per month". If you need a variable amount or more control over when the charges take place then use [recurring payments](/docs/recurring-payments) instead. ## Create a subscription [#create-a-subscription] Create a subscription by calling [`POST /requests`](/reference/payment-requests/createRequest) including the `subscription_options` block in place of a fixed `amount`. ```json title="POST https://api.volley.nz/v1/requests" { "bank_account_id": "bankaccount_ZYm2YeJXZ8Vzx3jjMLyp6", "message": "Gym membership", "type": "single", "subscription_options": { "amount": "25.00 NZD", "period": "weekly", "start_date": "2026-08-15", }, "cust_info": { "email": "jack@volley.nz" } } ``` ```json title="Response" { "request": { "id": "request_qTVSdEszuE9jfpTQaJ3j7", // ... "status": "active", "url": "https://app.volley.nz/pay/qTVSdEszuE9jfpTQaJ3j7", "subscription_options": { "amount": "25.00 NZD", "period": "weekly", "start_date": "2026-08-15", }, } } ``` You can redirect your customer to the returned `url` to have them authorise it via the [hosted page](/docs/hosted-page) or present the request on your own website with our [web embed](/docs/web-embed). Subscriptions requests are currently only supported for a single customer approval, i.e. `"type": "single"`. ### Subscription options [#subscription-options] | Field | Required | Description | | ------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `amount` | Required | The fixed amount to charge. | | `period` | Required | How often charges will be repeated, supports `weekly`, `fortnightly`, `monthly`, `quarterly`, or `annually`. | | `start_date` | Optional | The date and time of the first charge. Defaults to the time of the customer approving ongoing payments in their bank app if not provided. | | `end_date` | Optional | A date and time after which no further charges will be made. | | `total_count` | Optional | A cap on the total number of payments in this subscription. You can use this field if the subscription is intended as a fixed term, e.g. "1 year membership". | If a Subscription has an end date and a cap on the total count of payments, it will end once the first of these conditions becomes true. If neither are provided then the Subscription is open ended until you or your customer decides to cancel. Web SDK recurring ### Customer info [#customer-info] When setting up a Subscription, you may pass customer details to Volley to be recorded against the payment. At minimum a verified email address is required to allow customers to self-manage their Subscription through our customer dashboard at [my.volley.nz](https://my.volley.nz). ### Payment scheduling [#payment-scheduling] Volley will automatically schedule payments to be collected from the start date of a Subscription, repeating at the agreed frequency until any end conditions are met. If a `start_date` was provided in the subscription options of the original request, the first payment will be taken at 10am on this date and advance per the period afterwards. When no `start_date` is provided, the first payment is taken immediately at the time the customer authorises the consent in their banking app and scheduled payments will be made at the same time of day. If a `start_date` was provided but is in the past, the first payment will be taken immediately after authorisation. Charges recur on a calendar-aligned schedule based on the subscription's billing period, with the billing anchor (the day or day-of-month the subscription started) preserved for each run. | Period | Schedule | | ----------- | --------------------------------- | | Weekly | Every 7 days on the same weekday | | Fortnightly | Every 14 days on the same weekday | | Monthly | Same calendar day each month | | Quarterly | Same calendar day every 3 months | | Annually | Same calendar day each year | If a billing anchor doesn't exist (e.g. 31st of a month without 31 days), the charge is scheduled for the end of the month and then restored to the original anchor for subsequent months. For example: a monthly Subscription starting on Jan 31st will be scheduled as Jan 31st, Feb 28th, March 31st, April 30th, ... and so on. ### Retries [#retries] When a scheduled payment fails, Volley will retry automatically with the following schedule: * For weekly and fornightly Subscriptions, payments will be retried daily at days 1-2-3 after the scheduled charge date. * For monthly, quarterly, and annual Subscriptions, payments will be retried at days 1-3-5 after the scheduled charge date. If a retry attempt succeeds, the Subscription is advanced to the next charge date based on the original billing anchor. If all retry attempts fail for a scheduled charge it will be skipped and the Subscription will be advanced to the next charge date. ## Pause a Subscription [#pause-a-subscription] You can pause a Subscription through the API by calling `POST /v1/subscriptions/{id}/pause`. No other parameters are required aside from the subscription ID. A paused Subscription can be resumed by calling `POST /v1/subscriptions/{id}/resume`. A resumed subscription will begin again from the current date, i.e. the first scheduled payment will be counted from the current date + the period interval. ## Cancel a Subscription [#cancel-a-subscription] A subscription can be cancelled in three ways: 1. **By the customer through their bank**, directly in their bank app. 2. **By the customer through Volley**, using the self-service dashboard at [my.volley.nz](https://my.volley.nz). 3. **By you, on behalf of the customer**, for example, when a customer cancels through your UI or closes their account. Use the cancel endpoint below to cancel via the API. ### Cancel via the API [#cancel-via-the-api] Call `PUT /v1/subscriptions/{id}/cancel`. No other parameters aside from the subscription ID are required. Once called, no further payments will be executed against the subscription. ### Handle customer cancellation [#handle-customer-cancellation] Customers can manage their Subscriptions at [my.volley.nz](https://my.volley.nz) and can cancel a Subscription there or from their bank app. There are different failure modes to be aware of based on where a customer revoked their Subscription: * If cancelled through Volley, you'll receive a `subscription.cancelled` webhook immediately afterwards. * If cancelled through their bank, the next attempt to execute a payment will fail and mark the Subscription as cancelled and send the `subscription.cancelled` webhook. ## Webhook events [#webhook-events] Subscriptions move through their lifecycle asynchronously, charges are scheduled in the future, retries may run over several days, and customers can cancel or pause outside of your app. To keep track of state, you can subscribe to [webhooks](/docs/webhooks) so your backend is notified as each event occurs. Configure your webhook endpoint in the [Volley Dashboard](https://dashboard.volley.nz/webhooks), and you will receive a `POST` request to your endpoint each time a subscription event fires. See the [webhooks guide](/docs/webhooks) for details on payload structure, signature verification, and retry behaviour. The following events are sent over the lifecycle of a Subscription: | Event | When it's sent | | ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `subscription.created` | The customer has authorised the Subscription and it is now active. You can use this event to link a subscription to customer records in your system. | | `subscription.paused` | The Subscription has been paused via the API. No further charges will be scheduled until it is resumed. | | `subscription.resumed` | A previously paused Subscription has been resumed. Charges will begin again from the current date plus the period interval. | | `subscription.charge_failed` | A scheduled charge attempt failed. If retry attempts remain, `next_attempt_at` will indicate when the next retry will run. | | `subscription.charge_skipped` | A scheduled charge was skipped after all retry attempts were exhausted. The Subscription remains active and will advance to the next scheduled charge. | | `subscription.completed` | The final scheduled charge for a fixed-term Subscription (i.e. one with an `end_date` or `total_count`) has completed successfully. No further charges will occur. | | `subscription.cancelled` | The Subscription has been cancelled - either by you via the API, by the customer through Volley, or by the customer through their bank. No further charges will occur and you should revoke access to the associated product or service. | See the [Subscription events](/docs/webhooks#subscription-events) section of the webhooks guide for example payloads. # Support (/docs/support) All Volley plans include technical support within business hours by email. You can reach us at [support@volley.nz](mailto:support@volley.nz). If you require faster response times, 24/7 support, dedicated channels, or priority incident handling, [talk to us](mailto:hello@volley.nz) about moving to a Dedicated plan. # Web embed (/docs/web-embed) Volley's web embed lets you accept payments directly on your website without redirecting customers away. Add our JavaScript SDK to your page, and Volley renders a payment modal right in your checkout flow - handling bank selection, approval, and confirmation without the customer ever leaving your site. Display modes ## Try it out [#try-it-out] Click the button below to open the embed and make a $1.00 donation to [Canteen Aotearoa](https://canteen.org.nz/) ## Using the embed on your webpage [#using-the-embed-on-your-webpage] Add the Volley SDK to your page by including the script tag: ```html ``` Then create a checkout instance by passing in the requestId from a payment request you've already created server-side. ```js // createCheckout returns the instance immediately - nothing is rendered until // you call open() or mount(), and the checkout shows its own loading state // until the request has loaded. const checkout = Volley.createCheckout({ requestId: "request_qTVSdEszuE9jfpTQaJ3j7" }) // Call open to open the embed as a modal over the page checkout.open() // Or call mount to show the checkout within the page in an element you control checkout.mount("#checkout-container") ``` ### Pre-selecting a provider [#pre-selecting-a-provider] If you already know which bank your customer uses or you did bank selection in your own UI, you can skip the bank selection step by passing a provider: ```js // provider can be any of: "anz", "asb", "bnz", "kiwibank", or "westpac". const checkout = Volley.createCheckout({ requestId: "request_qTVSdEszuE9jfpTQaJ3j7", provider: "asb" }) ``` ### Handling a payment result [#handling-a-payment-result] ```js const checkout = Volley.createCheckout({ requestId: "request_qTVSdEszuE9jfpTQaJ3j7", onSuccess({ payment_id, request_id }) { // Payment completed - show a confirmation, redirect, or update your UI }, onCancel() { // Customer closed the checkout without completing payment }, onError({ payment_id, request_id }) { // Payment failed - show an error state or offer a retry }, }) checkout.open() ``` All three callbacks are optional, but we recommend implementing at least onSuccess and onError so you can update your UI immediately if necessary. For payment verification, don't rely solely on these client-side callbacks - always confirm the payment status server-side using the API and/or [webhooks](/docs/webhooks). ### Cleaning up [#cleaning-up] When you no longer need the checkout on the page, call destroy() to remove it from the DOM entirely. After calling destroy(), the instance can't be reused - call Volley.createCheckout() again if you need a new one. ```js checkout.destroy() ``` ## Setting an allowed domain [#setting-an-allowed-domain] Before you can use the embed on your webpage you need to configure the domain you will be hosting it from as an [allowed domain](https://dashboard.volley.nz/settings/domains) in the Volley Dashboard. The Volley embed renders inside an iframe on your page. For security, Volley only allows the iframe to load your requests on domains you've explicitly approved. Any embed attempts from an unlisted domain will be blocked by the browser. Because Volley ties allowed domains to your account, your payment requests can only ever appear on websites you control - if a request ID is leaked or guessed, it can't be embedded elsewhere. ## Embedding the iframe without our SDK [#embedding-the-iframe-without-our-sdk] If your site's security policy restricts third-party JavaScript, or you'd rather control the embedding experience yourself, you can skip the SDK and directly call the checkout iframe. You'll need to handle positioning it on the page (and rendering a backdrop if you want a modal-style overlay), building the request URL, mounting the iframe, and listening for `postMessage` events. ### Building the checkout URL [#building-the-checkout-url] Point the iframe's `src` at `https://app.volley.nz/checkout/v2` with the request ID and options as query parameters: ```js function buildCheckoutUrl({ requestId, provider, display }) { const params = new URLSearchParams() params.set("request_id", requestId) params.set("display", display) if (provider) params.set("provider", provider) return `https://app.volley.nz/checkout/v2?${params.toString()}` } ``` | Parameter | Required | Description | | ------------ | -------- | ---------------------------------------------------------------------------------------------------------------------------- | | `request_id` | yes | The ID of a payment request created server-side. | | `display` | yes | `modal` if you're rendering the iframe as an overlay with a backdrop, or `embed` if it lives inline within your page layout. | | `provider` | no | Skip bank selection by pre-selecting one of `anz`, `asb`, `bnz`, `kiwibank`, or `westpac`. | ### Listening for events [#listening-for-events] The iframe communicates with your page via `window.postMessage`. Attach a listener and dispatch on the message `type`: ```js const expectedOrigin = "https://app.volley.nz" window.addEventListener("message", (event) => { if (event.origin !== expectedOrigin) return if (event.source !== iframe.contentWindow) return const { type, ...data } = event.data ?? {} switch (type) { case "volley.ready": // Checkout has loaded and is ready to display break case "volley.resize": // Content height changed - resize the iframe to match iframe.style.height = `${data.height}px` break case "volley.error_state": // data.hasError indicates whether checkout is showing an error screen break case "volley.payment_successful": // data.payload contains { payment_id, request_id } break case "volley.payment_cancelled": // Customer cancelled the payment break case "volley.payment_failed": // data.payload contains { payment_id, request_id } break case "volley.load_failed": // The iframe couldn't load the request break case "volley.close_modal": // Customer dismissed the checkout - tear down your iframe and backdrop break } }) ``` Always verify `event.origin` matches Volley's origin before trusting a message, and check `event.source` against the specific iframe you're hosting so messages from other iframes on the page can't be confused for checkout events. As with the SDK, treat these client-side events as UI hints only and confirm payment status server-side via the API and/or [webhooks](/docs/webhooks). # Webhooks (/docs/webhooks) Volley sends webhooks to your backend as payment requests move through their lifecycle, e.g. when a payment is initiated, completed, or failed. This lets you react to events in real time without polling our API. You configure your webhook endpoint in the [Volley Dashboard](https://dashboard.volley.nz/webhooks). This must be a publicly accessible URL on your backend server. Volley wsends a `POST` request to your endpoint with a JSON payload describing each event and your server should respond with a `2XX` status to acknowledge receipt. Creating a webhook in the Volley dashboard All webhook requests include a signature header so you can verify they're coming from Volley and not a third party. See [authentication](#authentication) below. ## Authentication [#authentication] As your webhook endpoint is public to the internet, webhook requests include a header for signature verification. This allows you to check the request is legitimate and came from Volley and prevents malicious use or spoofing. Signatures are added as a header to the request to your webhook endpoint: ```plaintext X-Volley-Signature: sha256=f7bc83f4305... ``` You will be able to retrieve your webhook secret when creating each new webhook in the Dashboard. Your secret will only be displayed to you once, and you'll need to securely store it within your own backend (e.g. in secure environment variables or a runtime secrets manager). If you've lost access to your secret you will need to re-create your webhook. Receiving the webhook secret when creatign a webhook in the Volley dashboard ### Validating the signature [#validating-the-signature] You can validate the signature on an incoming webhook request by recomputing it using the entire request body as the payload. ```bash #!/bin/bash PAYLOAD='' BASE64_SECRET='' PROVIDED_SIG='sha256=... ' CALCULATED=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$(echo -n "$BASE64_SECRET" | base64 -d)" | cut -d' ' -f2) EXPECTED=$(echo "$PROVIDED_SIG" | cut -d'=' -f2) echo "Calculated: $CALCULATED" echo "Expected : $EXPECTED" if [ "$CALCULATED" = "$EXPECTED" ]; then echo "Valid" else echo "Invalid" fi ``` ```csharp [HttpPost] public IActionResult Webhook([FromBody] string payload) { string signature = Request.Headers["X-Volley-Signature"]; string secret = Configuration["WebhookSecret"]; if (WebhookValidator.ValidateWebhook(payload, secret, signature)) { // Process webhook return Ok(); } return Unauthorized(); } public static class WebhookValidator { public static bool ValidateWebhook(string payload, string base64Secret, string providedSignature) { try { byte[] secretKey = Convert.FromBase64String(base64Secret); using (var hmac = new HMACSHA256(secretKey)) { byte[] payloadBytes = Encoding.UTF8.GetBytes(payload); byte[] hash = hmac.ComputeHash(payloadBytes); string calculated = Convert.ToHexString(hash).ToLowerInvariant(); string expected = providedSignature.StartsWith("sha256=") ? providedSignature.Substring(7) : providedSignature; return calculated == expected; } } catch { return false; } } } ``` ```go func webhookHandler(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) payload := string(body) signature := r.Header.Get("X-Volley-Signature") secret := os.Getenv("WEBHOOK_SECRET") if validateWebhook(payload, secret, signature) { processWebhook(payload) w.WriteHeader(http.StatusOK) return } w.WriteHeader(http.StatusUnauthorized) } func validateWebhook(payload, base64Secret, providedSignature string) bool { secretKey, err := base64.StdEncoding.DecodeString(base64Secret) if err != nil { return false } h := hmac.New(sha256.New, secretKey) h.Write([]byte(payload)) calculated := hex.EncodeToString(h.Sum(nil)) expected := providedSignature if strings.HasPrefix(providedSignature, "sha256=") { expected = providedSignature[7:] } return hmac.Equal([]byte(calculated), []byte(expected)) } func processWebhook(payload string) { println("Processing webhook:", payload) } ``` ```ruby class WebhookController < ApplicationController skip_before_action :verify_authenticity_token def receive payload = request.body.read signature = request.headers['X-Volley-Signature'] secret = ENV['WEBHOOK_SECRET'] if WebhookValidator.validate(payload, secret, signature) process_webhook(payload) head :ok else head :unauthorized end end private def process_webhook(payload) Rails.logger.info "Processing webhook: #{payload}" end end class WebhookValidator def self.validate(payload, base64_secret, provided_signature) return false if base64_secret.nil? || provided_signature.nil? begin secret_key = Base64.decode64(base64_secret) calculated = OpenSSL::HMAC.hexdigest('sha256', secret_key, payload) expected = provided_signature.start_with?('sha256=') ? provided_signature[7..-1] : provided_signature OpenSSL.fixed_length_secure_compare(calculated, expected) rescue false end end end ``` ```javascript const express = require('express'); const crypto = require('crypto'); const app = express(); app.use('/webhook', express.raw({ type: 'application/json' })); app.post('/webhook', (req, res) => { const payload = req.body.toString(); const signature = req.headers['x-volley-signature']; const secret = process.env.WEBHOOK_SECRET; if (validateWebhook(payload, secret, signature)) { processWebhook(payload); res.status(200).send('OK'); } else { res.status(401).send('Unauthorized'); } }); function validateWebhook(payload, base64Secret, providedSignature) { if (!base64Secret || !providedSignature) return false; try { const secretKey = Buffer.from(base64Secret, 'base64'); const calculated = crypto.createHmac('sha256', secretKey) .update(payload, 'utf8') .digest('hex'); const expected = providedSignature.startsWith('sha256=') ? providedSignature.substring(7) : providedSignature; return crypto.timingSafeEqual( Buffer.from(calculated), Buffer.from(expected) ); } catch { return false; } } function processWebhook(payload) { console.log('Processing webhook:', payload); } ``` ## Retry behaviour [#retry-behaviour] Volley will retry webhook delivery, until a 2XX status is provided with the following conditions: * Backoff time: 0.25s to 10.0s with jitter * Maximum attempts: 20 * Maximum retry duration: 10 minutes If you've been unable to accept webhooks outside of these conditions [get in touch](mailto://support@volley.nz) and we can arrange re-delivery. ## Event types [#event-types] Volley will send webhooks for the events below. Each webhook contains a type field that identifies the event, and a data payload that is specific to each type. ### Request events [#request-events] ```json title="POST https://yourapp.com/webhooks" { "type": "request.created", "version": "1", "data": { "id": "request_8GbnJK6WrxGvPobCylFDO", "merchant_identifier": "e18330ee725f4553b88b0046e683190b", "user_id": "user_cEwCTcXyxhdFbJLyune6x", "created_at": "2025-02-12T08:30:00Z", "expires_at": "2025-02-12T08:35:00Z" } } ``` ```json title="POST https://yourapp.com/webhooks" { "type": "request.expired", "version": "1", "data": { "id": "request_8GbnJK6WrxGvPobCylFDO", "user_id": "user_cEwCTcXyxhdFbJLyune6x", "merchant_identifier": "e18330ee725f4553b88b0046e683190b", "status": "expired", "updated_at": "2025-02-12T08:45:00Z", } } ``` ```json title="POST https://yourapp.com/webhooks" { "type": "request.status_updated", "data": { "id": "request_8GbnJK6WrxGvPobCylFDO", "user_id": "user_cEwCTcXyxhdFbJLyune6x", "merchant_identifier": "e18330ee725f4553b88b0046e683190b", "status": "paid", "updated_at": "2025-02-12T08:45:00Z", } } ``` ### Payment events [#payment-events] ```json title="POST https://yourapp.com/webhooks" { "type": "payment.created", "version": "1", "data": { "id": "payment_Bzv6djpVl07tmMx2Tuode", "request_id": "request_8GbnJK6WrxGvPobCylFDO", "user_id": "user_cEwCTcXyxhdFbJLyune6x", "merchant_identifier": "e18330ee725f4553b88b0046e683190b", "status": "awaiting-consent", "updated_at": "2025-02-12T08:45:00Z", } } ``` ```json title="POST https://yourapp.com/webhooks" { "type": "payment.status_updated", "version": "1", "data": { "id": "payment_Bzv6djpVl07tmMx2Tuode", "request_id": "request_8GbnJK6WrxGvPobCylFDO", "user_id": "user_cEwCTcXyxhdFbJLyune6x", "merchant_identifier": "e18330ee725f4553b88b0046e683190b", "status": "[successful|cancelled|failed|unconfirmed]", "updated_at": "2025-02-12T08:45:00Z", } } ``` ### Recurring consent events [#recurring-consent-events] ```json title="POST https://yourapp.com/webhooks" { "type": "recurring_payment.authorised", "version": "1", "data": { "id": "recurring_8GbnJK6WrxGvPobCylFDO", "merchant_identifier": "e18330ee725f4553b88b0046e683190b", "status": "active", "token": "eyJhbGciOiJI..." } } ``` Sent when a payer has authorised a recurring payment consent. The `token` is only included on this event and must be stored securely, it is used to execute payments against the consent. ```json title="POST https://yourapp.com/webhooks" { "type": "recurring_payment.revoked", "version": "1", "data": { "id": "recurring_8GbnJK6WrxGvPobCylFDO", "merchant_identifier": "e18330ee725f4553b88b0046e683190b", "status": "revoked" } } ``` Sent when a payer revokes a recurring payment consent. Any stored token for this consent should be discarded. ### Subscription events [#subscription-events] ```json title="POST https://yourapp.com/webhooks" { "type": "subscription.created", "version": "1", "data": { "id": "subscription_HkT3nLxYr8Q2vDpMaEyBw", "request_id": "request_8GbnJK6WrxGvPobCylFDO", "user_id": "user_cEwCTcXyxhdFbJLyune6x", "merchant_identifier": "member_48213", "created_at": "2025-02-12T08:30:00Z" } } ``` ```json title="POST https://yourapp.com/webhooks" { "type": "subscription.charge_failed", "version": "1", "data": { "id": "subscription_HkT3nLxYr8Q2vDpMaEyBw", "user_id": "user_cEwCTcXyxhdFbJLyune6x", "merchant_identifier": "member_48213", "last_payment_id": "payment_Bzv6djpVl07tmMx2Tuode", "last_error": "Payment rejected by bank", "attempts": 1, "charge_at": "2026-08-15T10:00:00Z", "next_attempt_at": "2026-08-16T10:00:00Z", "updated_at": "2026-08-15T10:05:00Z" } } ``` Sent every time a scheduled charge attempt fails. If retries remain, `next_attempt_at` is set to the time of the next retry. When the final retry fails, this event is sent without `next_attempt_at` and is followed by a `subscription.charge_skipped` event. ```json title="POST https://yourapp.com/webhooks" { "type": "subscription.charge_skipped", "version": "1", "data": { "id": "subscription_HkT3nLxYr8Q2vDpMaEyBw", "user_id": "user_cEwCTcXyxhdFbJLyune6x", "merchant_identifier": "member_48213", "attempts": 3, "skipped_charge_at": "2026-08-15T10:00:00Z", "next_charge_at": "2026-09-15T10:00:00Z", "updated_at": "2026-08-18T10:05:00Z" } } ``` Sent when all retry attempts for a scheduled charge have been exhausted and the charge is skipped. The Subscription remains active and will attempt the next scheduled charge at `next_charge_at`. ```json title="POST https://yourapp.com/webhooks" { "type": "subscription.completed", "version": "1", "data": { "id": "subscription_HkT3nLxYr8Q2vDpMaEyBw", "user_id": "user_cEwCTcXyxhdFbJLyune6x", "merchant_identifier": "member_48213", "updated_at": "2025-02-12T08:45:00Z" } } ``` Sent when the final scheduled charge for a subscription has completed successfully. ```json title="POST https://yourapp.com/webhooks" { "type": "subscription.cancelled", "version": "1", "data": { "id": "subscription_HkT3nLxYr8Q2vDpMaEyBw", "user_id": "user_cEwCTcXyxhdFbJLyune6x", "merchant_identifier": "member_48213", "updated_at": "2025-02-12T08:45:00Z" } } ``` ```json title="POST https://yourapp.com/webhooks" { "type": "subscription.paused", "version": "1", "data": { "id": "subscription_HkT3nLxYr8Q2vDpMaEyBw", "user_id": "user_cEwCTcXyxhdFbJLyune6x", "merchant_identifier": "member_48213", "updated_at": "2025-02-12T08:45:00Z" } } ``` ```json title="POST https://yourapp.com/webhooks" { "type": "subscription.resumed", "version": "1", "data": { "id": "subscription_HkT3nLxYr8Q2vDpMaEyBw", "user_id": "user_cEwCTcXyxhdFbJLyune6x", "merchant_identifier": "member_48213", "updated_at": "2025-02-12T08:45:00Z" } } ``` ### Customer events [#customer-events] ```json title="POST https://yourapp.com/webhooks" { "type": "customer.created", "version": "1", "data": { "id": "customer_9YqZaMv2LkR7wPn4TxbCf", "user_id": "user_cEwCTcXyxhdFbJLyune6x", "created_at": "2025-02-12T08:30:00Z", "email": "jane@example.com", "first_name": "Jane", "last_name": "Doe", "phone": "+64211234567", "address": { "address1": "123 Queen Street", "address2": "", "address3": "", "address4": "", "city": "Auckland", "postcode": "1010" } } } ``` ```json title="POST https://yourapp.com/webhooks" { "type": "customer.updated", "version": "1", "data": { "id": "customer_9YqZaMv2LkR7wPn4TxbCf", "user_id": "user_cEwCTcXyxhdFbJLyune6x", "updated_at": "2025-02-12T08:45:00Z", "email": "jane@example.com", "first_name": "Jane", "last_name": "Doe", "phone": "+64211234567", "address": { "address1": "123 Queen Street", "address2": "", "address3": "", "address4": "", "city": "Auckland", "postcode": "1010" } } } ``` ## Recommendations [#recommendations] **Handle duplicates by tracking processed events.** Check if you've already processed a specific payment or request by storing its ID and status in your system. If the event has already been processed you can success response immediately. **Handle out-of-order delivery using timestamps.** Due to network failures and retries, webhooks may arrive out of chronological order. Always compare the updated\_at timestamp and ignore webhook updates that are older than what you've already processed to prevent overwriting newer data with stale information. **Respond with a 2xx status code within 15 seconds.** If your webhook handler needs more time to process actions like sending emails or updating external systems, we recommend using a queue to complete those actions asynchronously after responding to the webhook. **Return 204 to stop redeliveries.** Use a 204 status code if you want to acknowledge the webhook but stop further retry attempts. Any 4xx or 5xx response will cause Volley to retry the webhook delivery according to our retry policy. ### Local development [#local-development] To test webhooks with a local development server, Volley’s server needs to be able to reach your local machine over the internet using a tunnel service. We recommend using [webhook.site](https://webhook.site) which is a free service. Requests can viewed in the UI and forwarded to your local app over XHR (with CORS) and/or with the webhook.site CLI. # Android (/docs/native-webview/android) Embed Volley's hosted payment page in a `WebView`. Your app opens the page, the customer approves in their bank app, and the page sends a completion message to your app to let you update the UI. ## 1. Add the WebView [#1-add-the-webview] Create a payment request on your server, pass the returned URL to your app, and load it in a `WebView`. ```kotlin @Composable fun ContentView(requestUrl: String) { var showRequest by remember { mutableStateOf(false) } if (showPayment) { RequestWebView(url = requestUrl) { status -> showRequest = false } } else { Button(onClick = { showRequest = true }) { Text("Pay by bank") } } } ``` ## 2. Add the bridge [#2-add-the-bridge] Add a `VolleyBridge` and attach it to the WebView as `VolleyWebview`. Copy the whole class in - you only need to provide the `onComplete` handler. ```kotlin class VolleyBridge( private val context: Context, private val onResult: (String) -> Unit, ) { private val main = Handler(Looper.getMainLooper()) @JavascriptInterface fun onMessage(json: String) { val msg = JSONObject(json) when (msg.optString("type")) { "redirect" -> main.post { openBank(msg.getString("url")) } "complete" -> main.post { onComplete(msg.optString("status")) } } } // Open the bank URL natively so it launches the bank app (a WebView can't). private fun openBank(url: String) { val intent = Intent(Intent.ACTION_VIEW, url.toUri()).apply { addCategory(Intent.CATEGORY_BROWSABLE) // Launch the bank app as its own task. addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { addFlags(Intent.FLAG_ACTIVITY_REQUIRE_NON_BROWSER) } } runCatching { context.startActivity(intent) }.onFailure { // No bank app claims this URL - open it however the device can. context.startActivity( Intent(Intent.ACTION_VIEW, url.toUri()) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), ) } } } ``` Attach the bridge to the WebView: ```kotlin @SuppressLint("SetJavaScriptEnabled") @Composable fun RequestWebView(url: String, onComplete: (String) -> Unit) { AndroidView( factory = { context -> WebView(context).apply { settings.javaScriptEnabled = true settings.domStorageEnabled = true addJavascriptInterface(VolleyBridge(context, onComplete), "VolleyWebview") loadUrl(url) } }, ) } ``` ## 3. Handle redirect back to your app [#3-handle-redirect-back-to-your-app] After approving, the customer is redirected from their bank app to their default browser. From here Volley automatically redirects the customer to your payment request's redirect URL. Use a deep link for this URL to get the customer back to your app. **Development** - add an intent filter for a custom scheme and set the request's redirect URL to `yourscheme://return`: ```xml ``` **Production** - use an App Link: 1. Host `assetlinks.json` at `https://yourdomain.com/.well-known/assetlinks.json`: ```json [{ "relation": ["delegate_permission/common.handle_all_urls"], "target": { "namespace": "android_app", "package_name": "com.yourcompany.yourapp", "sha256_cert_fingerprints": ["YOUR:APP:SIGNING:SHA256"] } }] ``` 2. Add a verified intent filter, scoped to a path (e.g. `/return`) so it doesn't shadow the association file: ```xml ``` Remember to add the redirect domain as an [allowed domain](https://dashboard.volley.nz/settings/domains) in the Volley Dashboard. ## Test end-to-end [#test-end-to-end] The app-to-app redirect only works for **real bank apps** on a **physical device**. An emulator can't reproduce it. We recommend testing your integration against each bank app, including BNZ to test the decoupled flow. If the bank opens as a web page instead of its app, the hand-off isn't reaching the bank app - confirm `openBank` adds `FLAG_ACTIVITY_REQUIRE_NON_BROWSER`. If the bank app opens **inside your app** rather than as its own screen (easy to miss), you need the `FLAG_ACTIVITY_NEW_TASK` - without it the bank app (or its consent tab) stacks onto your task and shows your app's name in the toolbar. If you aren't returned to your app after completing a payment, confirm your App Link is verified (`adb shell pm get-app-links ` shows `verified`) and that the redirect domain is allow-listed in the Volley Dashboard. Also check the success and failure redirect URLs are set on the payment request you're testing. # Introduction (/docs/native-webview) Using a WebView, you can embed Volley's hosted payment page into your native app. Your app creates a payment request then opens a WebView with the hosted payment page. Your customer selects their bank and is redirected to their bank app to approve the payment. Once configured, Volley returns the customer to your app. ## Why a WebView? [#why-a-webview] The simplest integration is to redirect the customer to the hosted payment page in their default mobile browser - it's just a link. The customer leaves your app, completes the payment, and returns - either manually, or through the success and failure redirect URLs on the payment request, which your app handles as deep links. For an improved UX, you can embed the hosted payment page in a WebView — `WebView` on Android, `WKWebView` on iOS. This keeps the customer in your app while they view the request. They're sent to their bank app to approve the payment and returned to your app, where the hosted page shows success or failure. The WebView integration takes more upfront work, but reduces the number of redirects and gives your customers a smoother experience. This guide covers the WebView integration. {/* -image- */} ## Try out the example [#try-out-the-example] Grab the example apps to get straight to working code for both platforms: ``` git clone https://github.com/volley-payments/volley-example-webview ``` ## How it works [#how-it-works] There are two redirect methods for the payment flow: * **App-to-app.** The customer is automatically redirected from your app to their bank app to approve the payment, is returned to their default browser and is then sent back to your app via a deep link. * **Decoupled.** The customer receives a push notification from their bank app. Nothing redirects. Because the decoupled flow never automaticaly redirects, the hosted page **polls** the payment to completion and delivers the result to your app as a message over the bridge. The redirect flow uses the same message. So regardless of flow, your app gets the result from **one place: the completion message.** The flow looks like this: ``` ┌─────────────────────────────────────────────────────────────┐ │ Your app │ │ │ │ │ ▼ │ │ WebView (hosted page) ──── customer selects bank │ │ │ │ │ │ "redirect" message ──▶ your app opens the bank app │ └────┼────────────────────────────────────────────────────────┘ ▼ Bank app ──── customer approves │ ▼ Device browser ──── payment executes, then redirects back │ │ deep link ▼ ┌─────────────────────────────────────────────────────────────┐ │ Your app (foreground) │ │ │ │ │ ▼ │ │ WebView (still open, polling) ──▶ payment resolves │ │ │ │ │ │ "complete" message ──▶ you close the WebView │ └─────────────────────────────────────────────────────────────┘ ``` ### The message protocol [#the-message-protocol] The hosted page emits event messages your app can listen to using a bridge. There are two event types, `redirect` and `complete`. The `redirect` event passes the bank redirect URL which your app must use to initiate the app-to-app redirects. The WebView cannot redirect to the bank app itself, it must be managed by your app. The `complete` event is emitted at the end of the payment consent flow. Your app may listen to these events to close the WebView and update the UI. ```jsonc // Open this URL natively so the bank app launches (a WebView can't do this itself). { "type": "redirect", "url": "https://..." } // The payment reached a terminal state. { "type": "complete", "status": "succeeded", // "failed" | "cancelled" "bank": "anz", "paymentId": "payment_..." } ``` ## Prerequisites [#prerequisites] * A [payment request](/api/create-request) created with a redirect URL that deep-links back to your app. * The redirect **domain** added as an [allowed domain](https://dashboard.volley.nz/settings/domains) in the Volley Dashboard. Redirects to domains that aren't allowed are rejected. * For production: a verified association file hosted on your redirect domain. A custom scheme is fine for local development. ## Get started [#get-started] # iOS (/docs/native-webview/ios) Embed Volley's hosted payment page in a `WKWebView`. Your app opens the page, the customer approves in their bank app, and the page sends a completion message to your app to let you update the UI. ## 1. Add the WKWebView [#1-add-the-wkwebview] Create a payment request on your server, pass the request URL to your app, and load it in a `WKWebView` presented as a sheet. ```swift import SwiftUI import WebKit struct ContentView: View { let requestURL = "https://app.volley.nz/pay/" @State private var showRequest = false var body: some View { Button("Pay by bank") { showRequest = true } .sheet(isPresented: $showRequest) { WebView(url: requestURL) { result in showRequest = false } } } } ``` ## 2. Add the bridge [#2-add-the-bridge] Implement the `WebView` with a `WKScriptMessageHandler` coordinator to accept messages from the hosted payment page. Call `UIApplication.shared.open(url)` on the `redirect` event to redirect to the bank app. Implement an `onComplete` handler for the payment result. ```swift struct VolleyResult: Decodable { let status: String let bank: String? let paymentId: String? } struct WebView: UIViewRepresentable { let url: URL let onComplete: (VolleyResult) -> Void func makeCoordinator() -> Coordinator { Coordinator(onComplete: onComplete) } func makeUIView(context: Context) -> WKWebView { let config = WKWebViewConfiguration() config.userContentController.add(context.coordinator, name: "volleyWebview") let web = WKWebView(frame: .zero, configuration: config) web.load(URLRequest(url: url)) return web } func updateUIView(_ web: WKWebView, context: Context) {} static func dismantleUIView(_ web: WKWebView, coordinator: Coordinator) { web.configuration.userContentController .removeScriptMessageHandler(forName: "volleyWebview") } final class Coordinator: NSObject, WKScriptMessageHandler { let onComplete: (VolleyResult) -> Void init(onComplete: @escaping (VolleyResult) -> Void) { self.onComplete = onComplete } func userContentController(_ ucc: WKUserContentController, didReceive message: WKScriptMessage) { guard message.name == "volleyWebview", let body = message.body as? [String: Any], let type = body["type"] as? String else { return } switch type { case "redirect": if let s = body["url"] as? String, let url = URL(string: s) { UIApplication.shared.open(url) } case "complete": if let data = try? JSONSerialization.data(withJSONObject: body), let result = try? JSONDecoder().decode(VolleyResult.self, from: data) { onComplete(result) } default: break } } } } ``` ## 3. Redirect back to your app [#3-redirect-back-to-your-app] After approving, your customer is redirected from their bank app to their default browser. From here Volley automatically redirects the customer to your payment request's success redirect URL. Use a deep link for this URL to get the customer back to your app. The hosted payment page will poll the payment to completion and emit the `complete` event. **Development** - register a custom scheme in `Info.plist` (`CFBundleURLTypes`) and set the request's redirect URL to `yourscheme://return`. **Production** - use a Universal Link: 1. Host `apple-app-site-association` at `https://yourdomain.com/.well-known/apple-app-site-association`: ```json { "applinks": { "apps": [], "details": [ { "appID": "com.company.app", "paths": ["/return", "/return/*"] } ] } } ``` 2. Add the **Associated Domains** capability in Xcode: `applinks:yourdomain.com`. 3. Use a path (e.g. `/return`), not the bare domain, so it doesn't shadow the association file. Remember to add the redirect domain or scheme as an [allowed domain](https://dashboard.volley.nz/settings/domains) in the Volley Dashboard. ## Test end-to-end [#test-end-to-end] The app-to-app redirect only works for **real bank apps** on a **physical device**. The simulator can't reproduce it. We recommend testing your integration against each bank app, including BNZ to test the decoupled flow. If the bank opens in as a web URL, the hand-off isn't going through the bridge - confirm the `redirect` message calls `UIApplication.shared.open`. If you aren't returned to your app after completing a payment, check your Associated Domains and, while iterating, enable **Associated Domains Development** on the device so iOS fetches the association file directly instead of from its cache. Also check the success and failure URLs are set on the payment request you are testing.