--- url: /how-it-works.md --- # How it works Maytes lets customers **pay now or split the cost with friends**. You add a checkout to your online store, the customer pays in the Maytes window, and you capture the payment to finalize the order. Maytes handles the payment and splitting logic. Every integration starts with your backend **creating a checkout**. The request includes the order's line items, total, and redirect URLs. The customer then pays in the Maytes-hosted window. The checkout follows an **authorize, then capture** pattern, like a card pre-authorization. Authorization *holds* the customer's funds; capture moves the money. ## One order, two payments For a split, Maytes runs **two transactions** on the customer's card. This customer is the *lead*: 1. **The lead's own share** — charged when you capture the checkout. 2. **The mates' shares** — held as a second authorization on the lead's card. Mates have **48 hours** to pay Maytes. When the window closes, Maytes captures any unpaid amount from that authorization, so the lead covers mates who did not pay in time. You do not see or manage the second transaction; it is between Maytes and the customer. **Your full order total is secured when you capture**, whether or not the mates pay. Maytes handles splitting, reminders, and collection. After paying, the customer stays in Maytes to add their mates, then returns to your site. ## The webhook flow Maytes sends your backend a signed [`checkout.authorized` webhook](/webhooks) as soon as the payment is authorized. Your webhook handler captures server-to-server while the customer remains in the Maytes window. The webhook flow validates and captures the order within seconds of payment. The customer **stays in Maytes** to add mates on the web or in the Maytes app, where they can select contacts. If any details are missing, Maytes follows up by SMS. The customer reaches your `return_url` only after this step. 1. The customer chooses Maytes in your online store. 2. Your backend **creates a checkout** with Maytes and gets a `checkout_url`. 3. The customer authenticates and pays in the **Maytes checkout window**. Funds are *authorized* (held), not yet taken. 4. Maytes sends your backend a **`checkout.authorized` webhook**. The customer sees a payment-processing screen while your handler runs. 5. Your handler **captures** the checkout, or cancels it if you cannot fulfill. Capture is synchronous: when the call returns 200, the money has moved. 6. The customer adds the mates who will share the cost, then lands on your `return_url`. ```mermaid sequenceDiagram autonumber participant C as Customer participant M as Your backend participant K as Maytes C->>M: Choose "Pay with Maytes" M->>K: POST /api/merchant/v1/checkouts K-->>M: { checkout_uuid, checkout_url } M-->>C: Open checkout_url (Maytes checkout window) C->>K: Completes payment → authorized K->>M: Webhook: checkout.authorized Note over C,K: Customer sees "processing payment…" in the Maytes window M->>K: POST /api/merchant/v1/checkouts/{uuid}/capture K-->>M: 200 — status: "captured" Note over C,K: Customer continues in Maytes (optional split step) K-->>C: Redirect to your return_url when done ``` ::: warning Fulfill on capture, not on return_url In the webhook flow, **capture success is your fulfillment trigger**. After capture, the customer may spend several minutes adding mates or close the window without reaching `return_url`. The payment remains valid. Use `return_url` only as a confirmation page, never as proof that the order is paid. ::: Capture **immediately** from the webhook handler because the customer is waiting on a processing screen. If you cannot fulfill the order, for example because a final stock check fails, call [cancel](/capture-and-status#if-you-can-t-fulfill-the-order) to release the hold. See [Webhooks](/webhooks) for the full handler, signature verification, and delivery semantics. ## Amounts are in minor units Every API amount is an **integer in the currency's minor unit**. For AUD, `4500` means **AUD 45.00**. Never send decimals. All examples in these docs use AUD. ## Before you begin [Onboarding](/onboarding) covers setting up your merchant account, credentials, and Stripe payout account. The **[Maytes merchant portal](/portal)** provides: * **OAuth client credentials** (`client_id` + `client_secret`) — a separate pair for the sandbox and production environments. * Your **webhook subscription** — endpoint URL, event types, and the signing secret used to verify deliveries. * Your **merchant account configuration**, connected to your payout account. Keep your `client_secret` and webhook signing secret on your server — never expose either in browser code. ## Next * [Quickstart](/quickstart) — a working webhook-flow integration, step by step. * [Webhooks](/webhooks) — events, signature verification, and delivery semantics. * [Authentication](/authentication) — getting and using access tokens. --- --- url: /onboarding.md --- # Onboarding Onboarding gives you access to the merchant portal, the API credentials you generate there, and a payment-ready payout account. Onboarding is **email-based today**, so you'll complete it with the Maytes team. It will become self-service in the future, but the steps will stay the same. ## The steps ### 1. Contact Maytes Email the Maytes team to start onboarding. We'll collect your registered business name, contact email, country, and payout currency. ### 2. Get access to the merchant portal The Maytes team provisions your **merchant account** and access to the [merchant portal](/portal). Each environment has a separate portal, accounts, and data: | Environment | Portal | |---|---| | [Sandbox](/sandbox-environment) | [sandbox-portal.maytes.co](https://sandbox-portal.maytes.co) | | Production | [portal.maytes.co](https://portal.maytes.co) | Start in the sandbox. The setup works the same in both environments, but the sandbox never moves real money. Configure your integration in the portal. These details aren't sent by email: * **Generate your API credentials** — your backend uses the `client_id` and `client_secret` for every [API call](/authentication). Each portal issues credentials for its own environment, so sandbox and production credentials are separate and never interchangeable. The secret is **shown once**; store it in your secret manager immediately. * **Set up your webhook** — register the endpoint URL and events, then copy the **signing secret** used to [verify deliveries](/webhooks#verify-the-signature). This secret is also shown once. Send a `webhook.test` event from the portal to confirm your endpoint works before relying on it for a real payment. You also get a dedicated **Stripe connected account**, provisioned with your merchant account. Every customer payment flows through it, so **you remain the merchant of record**. Your [settlements](/portal#settlements) are paid from this account. ### 3. Complete your Stripe account setup (production only) **The sandbox doesn't require this step.** It uses a shared test payout account, so you can integrate and take test payments end to end without setting up Stripe. Complete this step only for production. For production, use the **Stripe-hosted onboarding link** to verify your business identity and connect your bank account for payouts. You enter these details on Stripe's secure pages; Maytes never sees or stores them. ::: warning Onboarding links are single-use and expire in minutes The Stripe setup link is **single-use** and expires **minutes** after it's generated. This is a Stripe security property, not a configurable timeout. Don't bookmark the link or forward it for later. An expired link doesn't lose your progress. Generate a fresh link **on demand** from the [merchant portal](/portal); it resumes setup for the same account where you left off. ::: Until setup is complete, your **production** account can't process payments. You can create checkouts, but customers can't pay. The sandbox is unaffected. ## What you end up with | Artifact | Where it comes from | Used for | |---|---|---| | Merchant account | Provisioned by Maytes | Your checkouts, settlements, and portal access. | | [Merchant portal](/portal) access (per environment) | Set up by Maytes | Generating credentials, configuring webhooks, watching settlements. | | `client_id` / `client_secret` (per environment) | You generate them in the portal | [Authenticating](/authentication) every API call. | | Webhook endpoint + signing secret (per environment) | You configure them in the portal | Receiving and [verifying](/webhooks#verify-the-signature) `checkout.authorized`. | | Stripe connected account | Stripe (managed via Maytes) | Processing customer payments and paying out your [settlements](/portal). You remain the merchant of record. | ## Next After onboarding: * [How it works](/how-it-works) — pick your integration flow. * [Quickstart](/quickstart) — a captured payment against the sandbox. * [Merchant portal](/portal) — where your settlements and configuration live. --- --- url: /authentication.md --- # Authentication The Maytes merchant API uses **OAuth 2.0 client credentials**. Your server exchanges its `client_id` and `client_secret` for a short-lived access token, then uses that token as a Bearer credential. All calls are server-to-server. **Never put your `client_secret` in browser or mobile code.** ## Environments Maytes has two environments, each with its **own base URL** and **own credentials** from the merchant portal. Every example on this site uses the production base URL: ``` https://api.maytes.co ``` Build against the **sandbox** first; no real money moves there. To go live, switch the base URL and credentials to production. The API shape is identical in both environments. See [Sandbox environment](/sandbox-environment) for the sandbox base URL and test cards. ## Get your credentials Get your `client_id` and `client_secret` from the **Maytes merchant portal**. Each environment has a separate pair that works only with its matching base URL. Store credentials server-side in environment variables or a secret manager. See [Security](/security). On the portal's **API credentials** page, generate a credential pair and view your `client_id`. The `client_secret` appears **only once** when generated or rotated, so store it immediately. Only **merchant admin** portal users can access this page. Ask Maytes to grant admin access if your team needs it. ### Rotating your client secret If you lose the secret or suspect a leak, rotate it from the portal's **API credentials** page and deploy the new value immediately. Rotation has **no grace window**: the old secret stops working at once. Existing access tokens remain valid until they expire, providing a short buffer before the next token request requires the new secret. ## Request an access token ```bash curl -s https://api.maytes.co/oauth/token \ -H 'content-type: application/json' \ -d '{ "grant_type": "client_credentials", "client_id": "YOUR_CLIENT_ID", "client_secret": "YOUR_CLIENT_SECRET" }' ``` The response contains an `access_token`. Send it on every merchant API call: ```http Authorization: Bearer ``` ## Token lifetime, caching, and refresh * Access tokens are **short-lived**. Cache the token and reuse it across requests. * **Don't compute expiry yourself.** When a token expires, the API returns **`401`**; fetch a new token and retry the request once. * The official [backend SDKs](/build-your-integration) implement this "use until 401, then refresh and retry" pattern with a shared token cache and safe concurrent refresh. ```mermaid sequenceDiagram autonumber participant M as Your backend participant K as Maytes API M->>K: POST /oauth/token (client_credentials) K-->>M: { access_token } M->>K: API call with Bearer token Note over M,K: ...later, token expires... M->>K: API call with stale token K-->>M: 401 M->>K: POST /oauth/token (refresh) K-->>M: { access_token } M->>K: Retry the original call K-->>M: 200 ``` ## Use the SDK instead With a [backend SDK](/build-your-integration), construct the client once with your credentials. It handles token requests, caching, and refresh after a 401: ```ts import { createMaytesApiClient } from '@maytes/api-client-js'; const maytes = createMaytesApiClient({ endpoint: 'https://api.maytes.co', clientId: process.env.MAYTES_CLIENT_ID!, clientSecret: process.env.MAYTES_CLIENT_SECRET!, }); ``` ## Auth errors | HTTP | Meaning | |---|---| | `401` | Missing, invalid, or expired access token — fetch a new token and retry. | | `403` | Your merchant account is disabled or not permitted for this operation. | See the full [error reference](/api-reference). --- --- url: /sandbox-environment.md --- # Sandbox environment Build and validate your integration in the isolated **sandbox environment** before going live. No real money moves, no real settlements occur, and sandbox accounts never appear in the live merchant portal. ## Sandbox base URL ``` https://sandbox-api.maytes.co ``` Use the **sandbox** `client_id` and `client_secret` from the merchant portal with this host. The API shape is identical to production; only the host and credentials change. See [Environments](/authentication#environments). ## Test cards Use these card numbers in the sandbox with any future expiry date, any 3-digit CVC, and any postal code. **Stripe** processes payments in the Maytes window. ### Successful payments | Card type | Number | Result | |---|---|---| | Visa | `4242 4242 4242 4242` | Charge succeeds. | | Visa (debit) | `4000 0566 5566 5556` | Charge succeeds. | | Mastercard | `5555 5555 5555 4444` | Charge succeeds. | | American Express | `3782 822463 10005` | Charge succeeds. | ### Declines | Card type | Number | Result | |---|---|---| | Visa | `4000 0000 0000 0002` | Generic decline. | | Visa | `4000 0000 0000 9995` | Insufficient funds. | | Visa | `4000 0000 0000 9987` | Lost card. | | Visa | `4000 0000 0000 9979` | Stolen card. | | Visa | `4000 0000 0000 0069` | Expired card. | | Visa | `4000 0000 0000 0127` | Incorrect CVC. | | Visa | `4000 0000 0000 0119` | Processing error. | Use these cards to test error handling. A declined checkout transitions to `failed`; don't fulfill the order. ### 3-D Secure authentication | Card type | Number | Behavior | |---|---|---| | Visa | `4000 0027 6000 3184` | Requires 3DS authentication; succeeds when the customer confirms. | | Visa | `4000 0082 6000 3178` | Requires 3DS authentication; fails when the customer confirms. | The customer sees Stripe's 3DS challenge inside the Maytes window. Approve or decline it to test both paths. See [Stripe's docs](https://stripe.com/docs/testing) for international cards, fraud-blocked numbers, AVS variations, and the full test-card catalog. ## Testing webhooks Make your webhook endpoint publicly reachable over HTTPS. For local development, put a tunnel such as ngrok or cloudflared in front of your server and subscribe the tunnel URL. 1. **Subscribe** your sandbox endpoint (portal, or the [subscription API](/webhooks#managing-your-subscription)) and store the signing secret it returns. 2. **Send a `webhook.test` event** from the portal. It bypasses your event-type filter and always arrives. Confirm [signature verification](/webhooks#verify-the-signature) passes and you respond 2xx. 3. **Check the delivery log** in the portal or through `GET /api/merchant/v1/webhooks/deliveries`. Every attempt records the HTTP status your endpoint returned. ## What to test before going live 1. **Happy path** — create a checkout, complete it with `4242 4242 4242 4242`, confirm your `checkout.authorized` handler fires, captures, and the checkout reaches `captured`. 2. **Signature rejection** — send a request to your webhook endpoint with a bad signature. Confirm you reject it with a 4xx and don't process it. 3. **Duplicate delivery** — replay the same event (same envelope `id`) at your handler. Confirm you don't fulfill twice. 4. **Decline** — pay with `4000 0000 0000 0002`. Confirm your code reacts to the `failed` status and doesn't fulfill the order. 5. **Abandonment** — start a checkout, close the Maytes window. Confirm the customer lands on your `cancel_url` and your order stays in the right state. 6. **Cancel** — for a checkout that authorized but you don't want to capture, call `/cancel`. Confirm the status reaches `voided` and you receive `checkout.voided` with `reason: "merchant_cancel"`. 7. **Amount mismatch** — send a `total_amount` that doesn't reconcile with your line items. Confirm you handle `422 invalid_amount_arithmetic`. 8. **Order-already-authorized** — retry your create call for the same `merchant_order_id`. Confirm you handle `409 order_already_authorized` by reusing the existing `checkout_uuid` instead of recreating. ## Sandbox data is isolated * **No real money moves.** Stripe in test mode never charges a real card; settlements never reach a bank account. * **Separate accounts.** Customer accounts created in the sandbox only exist in the sandbox. * **Separate portal.** Your sandbox merchant portal is separate from your live one. To go live, use the production credentials from the portal, send requests to **`https://api.maytes.co`**, and create a **production webhook subscription** with its own signing secret. Request shapes, status lifecycles, and error codes remain identical. ## Next * [Quickstart](/quickstart) — the webhook flow end to end, against this environment. * [Webhooks](/webhooks) — signature verification and delivery semantics. * [Authentication](/authentication) — how to mint a Bearer token from your credentials. * [API reference](/api-reference) — the full schema against the sandbox. --- --- url: /quickstart.md --- # Quickstart Use the [webhook flow](/how-it-works#the-webhook-flow) to create a **captured payment** in about 30 minutes. You will get a token, create a checkout, configure a capture webhook, and complete a payment. The guide uses raw `curl` so each API call is visible. ::: warning This is a proof of concept, not a production integration This guide deliberately omits requirements for production, including signature verification, deduplication, and error handling. Relevant steps flag these omissions. [Build your integration](/build-your-integration) shows how to implement them with the [backend SDK](/build-your-integration). ::: Run the guide in the **[sandbox environment](/sandbox-environment)** so no real money moves. The examples show the production base URL; replace it with the sandbox base URL and use sandbox credentials. ## Prerequisites * A Maytes **merchant account** with merchant portal access. See [Onboarding](/onboarding) if needed. * Your `client_id` and `client_secret`, from the portal (use the **sandbox** pair while testing). * A server that can make HTTPS requests. This guide uses `curl`; production integrations should use the [backend SDK](/build-your-integration). * From Step 3 onward, a **publicly reachable HTTPS endpoint** for webhooks. For local development, place a tunnel such as ngrok or cloudflared in front of your development server. The base URL throughout this guide: ``` https://api.maytes.co ``` For testing, replace this with the base URL from [Sandbox environment](/sandbox-environment). ## Step 1 — Get an access token Exchange your OAuth 2.0 **client credentials** for a short-lived access token. ```bash curl -s https://api.maytes.co/oauth/token \ -H 'content-type: application/json' \ -d '{ "grant_type": "client_credentials", "client_id": "YOUR_CLIENT_ID", "client_secret": "YOUR_CLIENT_SECRET" }' ``` Export the returned `access_token`. Every later call sends it as a Bearer token: ```bash export ACCESS_TOKEN='paste_the_access_token_here' ``` See [Authentication](/authentication) for caching and refresh. ::: tip Use the SDK The [backend SDKs](/build-your-integration) fetch, cache, and refresh tokens. You provide `client_id` and `client_secret` once. This quickstart continues with raw HTTP to show each call. ::: ## Step 2 — Create a checkout Create a checkout with the order's line items and total. `return_url` and `cancel_url` receive the customer after completion or abandonment. In the webhook flow, `return_url` is a confirmation page, **not** the fulfillment trigger. Capture happens in your webhook handler in Step 5. ```bash curl -s https://api.maytes.co/api/merchant/v1/checkouts \ -H "authorization: Bearer $ACCESS_TOKEN" \ -H 'content-type: application/json' \ -d '{ "merchant_order_id": "order_1001", "total_amount": 4500, "currency": "AUD", "return_url": "https://shop.example.com/thanks?order=order_1001", "cancel_url": "https://shop.example.com/cart", "items": [ { "item_ref": "GA-1", "name": "General Admission", "quantity": 1, "unit_price": 4500, "currency": "AUD", "category": "ticket", "custom_data": { "event_id": "evt_123", "event_start_at": "2026-12-05T19:00:00Z", "timezone": "Australia/Sydney" } } ] }' ``` Response: ```json { "data": { "checkout_uuid": "019fbb9e-ecd1-7059-99f3-f8bf47b63931", "status": "created", "checkout_url": "https://checkout.maytes.co/?id=019fbb9e-ecd1-7059-99f3-f8bf47b63931", "expires_at": null } } ``` Keep these two response fields: * **`checkout_uuid`** — the order's identifier in Maytes. Webhooks, capture, status lookups, and support tooling use it. **In production, store it with your order record.** For this guide, export it: ```bash export CHECKOUT_UUID='019fbb9e-ecd1-7059-99f3-f8bf47b63931' ``` * **`checkout_url`** — the Maytes-hosted payment page used in Step 3. An unpaid checkout is kept for **one hour** after creation (`expires_at` is null unless you sent `merchant_expires_at`). You may exceed this while configuring webhooks, so keep the create command available. Step 6 uses it to create a new payable checkout. See [Create a checkout](/create-checkout) for fees, discounts, tax, item categories, and amount rules. ## Step 3 — Send the customer to the Maytes window Send the customer to the `checkout_url` returned in Step 2. It points to the Maytes-hosted checkout and includes the `checkout_uuid` as the `id` query parameter: ``` https://checkout.maytes.co/?id=019fbb9e-ecd1-7059-99f3-f8bf47b63931 ``` **Use the returned URL; do not construct it.** Each environment has a different host. Hardcoding the URL can send sandbox customers to production. Open the URL with the [Maytes Checkout Button](/checkout-button), which opens it in a popup on wide screens and in the same tab on phones, or redirect the browser yourself: ```js res.redirect(checkout.data.checkout_url); ``` Maytes handles the following steps in the window: 1. The customer **signs in** with their mobile number and the code sent by SMS. 2. The customer **chooses how to split** the cost between their payment and their mates. 3. The customer **pays by card**. The funds are now *authorized*: held, not taken. 4. Maytes sends `checkout.authorized` to your backend. Steps 4–5 handle this event. Complete these steps in Step 6, after your capture handler exists. Otherwise, the uncaptured authorization is [auto-voided after 2 minutes](/capture-and-status#when-to-call-capture). ## Step 4 — Subscribe your webhook endpoint Register your endpoint for **`checkout.authorized`** and **`checkout.voided`**. The first event tells your backend that a payment is ready to capture. ### Option A — in the merchant portal In the merchant portal, open **webhook configuration** and set: * **Endpoint URL** — the public HTTPS URL that receives POST requests, for example `https://shop.example.com/webhooks/maytes`. For local development, use your ngrok or cloudflared tunnel. * **Event types** — tick `checkout.authorized` and `checkout.voided`. Save the configuration. The portal shows your **signing secret** and also provides the [delivery log](/webhooks#the-delivery-log) and `webhook.test` button. Portal access is set up during [onboarding](/onboarding#_2-get-access-to-the-merchant-portal). ### Option B — via the API To script the same setup: ```bash curl -s https://api.maytes.co/api/merchant/v1/webhooks/subscription \ -H "authorization: Bearer $ACCESS_TOKEN" \ -H 'content-type: application/json' \ -d '{ "url": "https://shop.example.com/webhooks/maytes", "event_types": ["checkout.authorized", "checkout.voided"] }' ``` Whichever option you use, the **`signing_secret` is shown once — store it now**. This guide does not use it, but production handlers must [verify every delivery's signature](/webhooks#verify-the-signature) before trusting the request. Send a **`webhook.test`** event from the portal. Continue only after your endpoint receives it and returns a 2xx response. ## Step 5 — Implement the webhook handler When a customer completes payment, Maytes POSTs `checkout.authorized` to your endpoint. Read its `checkout_uuid` and **capture immediately** while the customer waits on a processing screen. The authorization is auto-voided after **2 minutes**. For this proof of concept, call capture and return 200: ``` POST /webhooks/maytes event = the JSON body if event.type == "checkout.authorized": POST /api/merchant/v1/checkouts/{event.data.checkout_uuid}/capture respond 200 ``` Use the existing Bearer token. An empty `{}` body captures the full amount: ```bash curl -sX POST https://api.maytes.co/api/merchant/v1/checkouts/$CHECKOUT_UUID/capture \ -H "authorization: Bearer $ACCESS_TOKEN" \ -H 'content-type: application/json' \ -d '{}' ``` Capture is **synchronous**. A 200 response means the money has moved and is your fulfillment trigger. ::: danger Don't ship this handler This handler trusts every POST request. Anyone who learns the URL could forge `checkout.authorized` and cause you to fulfill an unpaid order. A production handler **verifies the signature first** with the [backend SDK](/build-your-integration): ```ts const event = verifyWebhookSignature(rawBody, signatureHeader, signingSecret); ``` The same call is available in all seven languages. See [Verify the signature](/webhooks#verify-the-signature) for complete examples. The [assembled handler](/capture-and-status#putting-it-together-your-webhook-handler) adds deduplication and cancellation when you cannot fulfill. ::: ## Step 6 — Pay it, and watch it land You now have a working checkout call, webhook subscription, and capture handler. **Create a payable checkout.** Repeat the [Step 2 request](#step-2-—-create-a-checkout), because the earlier checkout has probably passed its 15-minute window. Export the new identifier: ```bash export CHECKOUT_UUID='the_new_checkout_uuid' ``` **Open its `checkout_url`** in a browser and complete [Step 3](#step-3-—-send-the-customer-to-the-maytes-window): 1. **Sign in** with your mobile number and enter the SMS code. Use a phone you can access. 2. **Choose how to split** the cost. 3. **Pay** with the test card `4242 4242 4242 4242` — any future expiry, any CVC (see [test cards](/sandbox-environment#test-cards)). **Watch your server logs** after submitting the payment: 1. Your endpoint receives **`checkout.authorized`** — the delivery you subscribed to in Step 4. 2. Your handler **captures**, and gets `status: "captured"` back. The money has moved. 3. The processing screen clears. The customer **adds the mates** sharing the cost, then lands on the `return_url` from Step 2. **Fulfill on capture, not on the `return_url` landing.** The customer may spend several minutes adding mates or close the tab before reaching your page. The payment remains valid. **Confirm the status independently**, as your support tooling should: ```bash curl -s https://api.maytes.co/api/merchant/v1/checkouts/$CHECKOUT_UUID \ -H "authorization: Bearer $ACCESS_TOKEN" ``` ```json { "data": { "checkout_uuid": "019fbb9e-ecd1-7059-99f3-f8bf47b63931", "status": "captured", "amount": 4500, "currency": "AUD", "captured_at": "2026-09-14T03:31:12.000Z" } } ``` `"status": "captured"` confirms a complete payment. ::: tip Nothing arrived at your endpoint? Check the **webhook delivery log** in the portal or call [`GET /webhooks/deliveries`](/webhooks#the-delivery-log). It records each attempt and your endpoint's HTTP response. If the checkout is `voided` with `reason: "auth_expired"`, your handler did not capture within the [2-minute window](/capture-and-status#when-to-call-capture). ::: ## Go live 1. **Run the [sandbox pre-go-live checklist](/sandbox-environment#what-to-test-before-going-live)** — declines, abandonment, cancellation, amount-mismatch errors, and `webhook.test` against your production endpoint. 2. **Handle failures** — respond to [error codes](/api-reference), such as `invalid_amount_arithmetic` and `not_capturable`, instead of assuming success. 3. **Switch to production** — use `https://api.maytes.co`, your production `client_id` and `client_secret`, and a production webhook subscription with its own signing secret. ## Next steps * Read [Webhooks](/webhooks) for signature verification, delivery semantics, and secret rotation. * Read [Capture & checkout status](/capture-and-status) for the status lifecycle, cancel, and idempotency. * Build richer carts with [line items, fees, discounts, and tax](/create-checkout). * Drop the [checkout button](/checkout-button) onto your online store. --- --- url: /build-with-ai.md --- # Build with AI Give your AI coding agent our docs and the checkout button skill, then review its work before you go live. * [`/llms.txt`](/llms.txt): an index of every page. Add `.md` to any page URL for its Markdown. * [`/llms-full.txt`](/llms-full.txt): the whole guide in one file. * [Agent skill](https://github.com/kttipay/maytes-checkout-button/tree/main/skills/maytes-checkout-button): teaches your agent to add the checkout button, in the open Agent Skills format. ## Before you go live * Your API credentials are only on your server. * Your server prices the cart from your own catalogue. * The button's `environment` matches your credentials. * Your webhook captures on `checkout.authorized` within 2 minutes. * One order completes in the [sandbox](/sandbox-environment). --- --- url: /build-your-integration.md --- # Build your integration The [Quickstart](/quickstart) uses raw `curl` for a proof of concept. The production integration guides use the [backend SDK](#the-sdk-does-the-boring-parts). Follow these chapters in order: | Chapter | What you'll do | |---|---| | **[Create a checkout](/create-checkout)** | Turn a cart into a `checkout_url` for the customer | | **[Webhooks](/webhooks)** | Receive and verify authorization events | | **[Capture & checkout status](/capture-and-status)** | Capture the payment and read checkout state for reconciliation | To manage the front-end launch, use the [Maytes Checkout Button](/checkout-button). It opens the Maytes checkout in a popup on wide screens and in the same tab on phones. Already taking payments with **Stripe**? Put the button straight into your [express row](/stripe-express-checkout), or offer Split with Maytes as a custom payment method in your [Payment Element](/stripe). The three chapters above still apply — only the front-end launch differs. ## Use the SDK Maytes provides official **server-to-server SDKs** for **TypeScript, Python, PHP, Go, Java, Ruby, and .NET**. All SDKs are generated from one API definition, expose the same operations, and stay aligned with the API. Examples are available in all seven languages. Use the SDK **only on your backend**. Keep `client_id`, `client_secret`, and the webhook signing secret server-side; never put them in browser code. Get credentials from the [merchant portal](/portal) after completing [Onboarding](/onboarding). ### Install it ::: tip One language, whole site Pick your language in any code block below and every example on every page switches to it, including next time you visit. ::: ::: code-group ```bash [TypeScript] npm install @maytes/api-client-js # or: pnpm add @maytes/api-client-js · yarn add @maytes/api-client-js ``` ```bash [Python] pip install maytes-api-client-py ``` ```bash [PHP] composer require maytes/api-client-php ``` ```bash [Go] go get github.com/kttipay/maytes-api-client-go ``` ```bash [Java] # Not on Maven Central yet. Build from source: git clone https://github.com/kttipay/maytes-api-client-java cd maytes-api-client-java && mvn install ``` ```bash [Ruby] gem install maytes-api-client-rb # or in a Gemfile: gem 'maytes-api-client-rb' ``` ```bash [.NET] dotnet add package maytes-api-client-net ``` ::: ### The SDK does the boring parts * **Authentication is automatic.** Construct the client once with your credentials. On first use, the SDK mints and caches an access token. After a `401`, it refreshes the token and retries automatically. You do not call `/oauth/token`; [Authentication](/authentication) explains the underlying flow. * **Webhook verification takes one call.** The SDK authenticates signed deliveries and returns the parsed event. See [Verify the signature](/webhooks#verify-the-signature). * **Requests and responses are typed.** Amounts remain integers, and timestamps parse to the language's date type. ### What it covers | Operation | Chapter | |---|---| | `CreateCheckout` | [Create a checkout](/create-checkout) | | `GetCheckout` | [Reading the checkout status](/capture-and-status#reading-the-checkout-status) | | `CaptureCheckout` | [Implement capture](/capture-and-status#implement-capture) | | `CancelCheckout` | [If you can't fulfill the order](/capture-and-status#if-you-can-t-fulfill-the-order) | | `RefundCheckout` | [Refunds](/refunds) | | `GetHealth` | Liveness probe | | `verifyWebhookSignature` | [Verify the signature](/webhooks#verify-the-signature) — authenticates an inbound delivery without making an API call | ::: warning Not in the SDK The SDK does not currently manage [webhook subscriptions](/webhooks#managing-your-subscription). Use the portal or call the subscription endpoints with raw HTTP and the same Bearer token. ::: ## Construct the client Construct the client **once at startup**, not once per request. Recreating it discards the cached access token and mints a new token for every call. ::: code-group ```ts [TypeScript] import { createMaytesApiClient } from '@maytes/api-client-js'; export const maytes = createMaytesApiClient({ endpoint: 'https://api.maytes.co', clientId: process.env.MAYTES_CLIENT_ID!, clientSecret: process.env.MAYTES_CLIENT_SECRET!, }); ``` ```python [Python] from maytes_api_client import create_maytes_api_client maytes = create_maytes_api_client( "https://api.maytes.co", client_id=os.environ["MAYTES_CLIENT_ID"], client_secret=os.environ["MAYTES_CLIENT_SECRET"], ) ``` ```php [PHP] use MaytesApiClient\ClientFactory; $maytes = ClientFactory::create( 'https://api.maytes.co', getenv('MAYTES_CLIENT_ID'), getenv('MAYTES_CLIENT_SECRET'), ); ``` ```go [Go] import maytes "github.com/maytes/api-client-go" api := maytes.CreateMaytesAPIClient( "https://api.maytes.co", os.Getenv("MAYTES_CLIENT_ID"), os.Getenv("MAYTES_CLIENT_SECRET"), ) ``` ```java [Java] import co.maytes.api.DefaultApi; import co.maytes.api.auth.ClientFactory; DefaultApi maytes = ClientFactory.create( "https://api.maytes.co", System.getenv("MAYTES_CLIENT_ID"), System.getenv("MAYTES_CLIENT_SECRET")); ``` ```ruby [Ruby] require 'maytes_api_client' MAYTES = MaytesApiClient.create_maytes_api_client( endpoint: 'https://api.maytes.co', client_id: ENV['MAYTES_CLIENT_ID'], client_secret: ENV['MAYTES_CLIENT_SECRET'], ) ``` ```csharp [.NET] using MaytesApiClient.Auth; var maytes = ClientFactory.Create( "https://api.maytes.co", Environment.GetEnvironmentVariable("MAYTES_CLIENT_ID"), Environment.GetEnvironmentVariable("MAYTES_CLIENT_SECRET")); ``` ::: In production, use `https://api.maytes.co`. During development, use the [sandbox endpoint](/sandbox-environment) and sandbox credentials. Later examples assume a `maytes` client configured as shown above. ## Two things that are true everywhere **Amounts use integer minor units.** `4500` means **AUD 45.00**. Never send decimals in any monetary field. **Capture moves money; authorization does not.** Payment places a *hold* on the customer's funds. Your capture call takes the funds. Until capture, nothing is settled, and the authorization is [auto-voided after 2 minutes](/capture-and-status#when-to-call-capture). ## Prefer raw HTTP? Every operation is a JSON endpoint documented in the [API reference](/api-reference), including schemas, examples, and error codes. With raw HTTP: * You must mint and refresh tokens yourself. See [Authentication](/authentication). * **You should still verify webhooks with the SDK.** Verification needs no credentials and makes no network call, so it can run alongside your HTTP client. ## Next Start with **[Create a checkout](/create-checkout)**. --- --- url: /create-checkout.md --- # Create a checkout A **checkout** represents your order in Maytes. Create it on your server before the customer enters the Maytes-hosted experience. Maytes returns: * A **`checkout_uuid`** — the order's identifier in Maytes. * A **`checkout_url`** — where the customer pays. Create checkouts **server-side** because the call requires OAuth credentials that must never reach the browser. With the [Maytes Checkout Button](/checkout-button), the button calls your backend, your backend creates the checkout, and the button launches Maytes. *** ## How the create call fits in | Step | Where | What happens | | ----------------------------------------- | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 1. **Customer clicks the button** | Your page | The button invokes your `createCheckout` callback. | | 2. **Create the checkout** | Your backend | Your endpoint calls [`POST /api/merchant/v1/checkouts`](/api-reference#api-1/tag/checkout-merchant/POST/api/merchant/v1/checkouts) with the cart and the URLs, and returns the `checkout_uuid`. | | 3. **Customer pays in the Maytes window** | Maytes | Funds are authorized (held). | | 4. **Capture** | Your backend | Your `checkout.authorized` [webhook](/how-it-works#the-webhook-flow) handler captures immediately. | See the **[API reference](/api-reference)** for complete request and response schemas, errors, and line-item categories. *** ## Why create a checkout? Create sends Maytes the order details needed to: * **Show the customer what they're paying for.** Line items, fees, discounts, tax, and the total populate the split-bill UI that the customer and their friends see. * **Validate the order math.** Maytes reconciles the items + fees + discounts + tax against `total_amount`; if it doesn't add up we reject with `422 invalid_amount_arithmetic`. See [Amount rules](#amount-rules) below. * **Redirect the customer.** `return_url` and `cancel_url` tell the Maytes-hosted window where to send the customer after completion or cancellation. * **Tie our checkout to your order.** Pass `merchant_order_id` so you can reconcile against your store later, and so we can reject duplicate create attempts for the same order with `409 order_already_authorized`. *** ## What you do with `checkout_uuid` Persist it against your order as soon as create returns. Every subsequent checkout call needs it: | Later step | Endpoint | | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | Capture the payment | [`POST /api/merchant/v1/checkouts/{uuid}/capture`](/api-reference#api-1/tag/checkout-merchant/POST/api/merchant/v1/checkouts/{checkoutUuid}/capture) | | Cancel before capture (release the auth) | [`POST /api/merchant/v1/checkouts/{uuid}/cancel`](/api-reference#api-1/tag/checkout-merchant/POST/api/merchant/v1/checkouts/{checkoutUuid}/cancel) | | Read the checkout's current status | [`GET /api/merchant/v1/checkouts/{uuid}`](/api-reference#api-1/tag/checkout-merchant/GET/api/merchant/v1/checkouts/{checkoutUuid}) | *** ## A minimal create request Assumes a `maytes` client [constructed at startup](/build-your-integration#construct-the-client). ::: code-group ```ts [TypeScript] const created = await maytes.createCheckout({ createCheckoutRequest: { merchantOrderId: 'order_1001', totalAmount: 4500, // minor units — 4500 = AUD 45.00 currency: 'AUD', returnUrl: 'https://shop.example.com/thanks?order=order_1001', cancelUrl: 'https://shop.example.com/cart', // `LineItem` is a union discriminated on `category`. In TypeScript the // object literal IS the variant — set `category` and the right shape is // required. Field names are camelCase; the wire format is snake_case. items: [ { itemRef: 'GA-1', name: 'General Admission', quantity: 1, unitPrice: 4500, currency: 'AUD', category: 'other', }, ], }, }); // Persist this against your order — everything later keys off it. const checkoutUuid = created.data.checkoutUuid; const checkoutUrl = created.data.checkoutUrl; ``` ```python [Python] from maytes_api_client.models.create_checkout_request import CreateCheckoutRequest from maytes_api_client.models.line_item import LineItem from maytes_api_client.models.other_line_item import OtherLineItem created = maytes.create_checkout(CreateCheckoutRequest( merchant_order_id="order_1001", total_amount=4500, # minor units — 4500 = AUD 45.00 currency="AUD", return_url="https://shop.example.com/thanks?order=order_1001", cancel_url="https://shop.example.com/cart", # Wrap the CATEGORY VARIANT in LineItem. Passing the fields straight to # LineItem(...) is accepted silently and then serialises to `null`. items=[LineItem(OtherLineItem( item_ref="GA-1", name="General Admission", quantity=1, unit_price=4500, currency="AUD", category="other", ))], )) # Persist this against your order — everything later keys off it. checkout_uuid = created.data.checkout_uuid checkout_url = created.data.checkout_url ``` ```php [PHP] use MaytesApiClient\Model\CreateCheckoutRequest; use MaytesApiClient\Model\OtherLineItem; $created = $maytes->createCheckout(new CreateCheckoutRequest([ 'merchant_order_id' => 'order_1001', 'total_amount' => 4500, // minor units — 4500 = AUD 45.00 'currency' => 'AUD', 'return_url' => 'https://shop.example.com/thanks?order=order_1001', 'cancel_url' => 'https://shop.example.com/cart', // Use the category VARIANT, not the base LineItem: the generated parent // serialises `category` as its own class name, which the API rejects. 'items' => [new OtherLineItem([ 'item_ref' => 'GA-1', 'name' => 'General Admission', 'quantity' => 1, 'unit_price' => 4500, 'currency' => 'AUD', 'category' => 'other', ])], ])); // Persist this against your order — everything later keys off it. $checkoutUuid = $created->getData()->getCheckoutUuid(); $checkoutUrl = $created->getData()->getCheckoutUrl(); ``` ```go [Go] // Build the category variant, then lift it into the LineItem union. item := maytes.OtherLineItemAsLineItem( maytes.NewOtherLineItem("GA-1", "General Admission", 1, 4500, "AUD", "other"), ) createReq := *maytes.NewCreateCheckoutRequest( 4500, "AUD", // minor units — 4500 = AUD 45.00 "https://shop.example.com/thanks?order=order_1001", "https://shop.example.com/cart", []maytes.LineItem{item}, ) createReq.SetMerchantOrderId("order_1001") created, _, err := api.DefaultAPI.CreateCheckout(ctx). CreateCheckoutRequest(createReq).Execute() if err != nil { /* handle */ } // Persist this against your order — everything later keys off it. checkoutUuid := created.Data.GetCheckoutUuid() checkoutURL := created.Data.GetCheckoutUrl() ``` ```java [Java] import co.maytes.api.model.*; import java.net.URI; import java.util.List; CreateCheckoutResponse created = maytes.createCheckout( new CreateCheckoutRequest() .merchantOrderId("order_1001") .totalAmount(4500) // minor units — 4500 = AUD 45.00 .currency("AUD") .returnUrl(URI.create("https://shop.example.com/thanks?order=order_1001")) .cancelUrl(URI.create("https://shop.example.com/cart")) // Wrap the category variant — the base LineItem has no field builders. .items(List.of(new LineItem(new OtherLineItem() .itemRef("GA-1") .name("General Admission") .quantity(1) .unitPrice(4500) .currency("AUD") .category(OtherLineItem.CategoryEnum.OTHER))))); // Persist this against your order — everything later keys off it. String checkoutUuid = created.getData().getCheckoutUuid(); String checkoutUrl = created.getData().getCheckoutUrl(); ``` ```ruby [Ruby] created = MAYTES.create_checkout( MaytesApiClient::CreateCheckoutRequest.new( merchant_order_id: 'order_1001', total_amount: 4500, # minor units — 4500 = AUD 45.00 currency: 'AUD', return_url: 'https://shop.example.com/thanks?order=order_1001', cancel_url: 'https://shop.example.com/cart', # Pass the category variant directly; LineItem is a oneOf wrapper. items: [MaytesApiClient::OtherLineItem.new( item_ref: 'GA-1', name: 'General Admission', quantity: 1, unit_price: 4500, currency: 'AUD', category: 'other', )], ), ) # Persist this against your order — everything later keys off it. checkout_uuid = created.data.checkout_uuid checkout_url = created.data.checkout_url ``` ```csharp [.NET] using MaytesApiClient.Model; var created = await maytes.CreateCheckoutAsync(new CreateCheckoutRequest( totalAmount: 4500, // minor units — 4500 = AUD 45.00 currency: "AUD", returnUrl: "https://shop.example.com/thanks?order=order_1001", cancelUrl: "https://shop.example.com/cart", // Wrap the category variant — LineItem's constructors take variants only. items: new List { new LineItem(new OtherLineItem( itemRef: "GA-1", name: "General Admission", quantity: 1, unitPrice: 4500, currency: "AUD", category: OtherLineItem.CategoryEnum.Other)), }) { MerchantOrderId = "order_1001", }); // Persist this against your order — everything later keys off it. var checkoutUuid = created.Data.CheckoutUuid; var checkoutUrl = created.Data.CheckoutUrl; ``` ::: Response: ```json { "data": { "checkout_uuid": "019fbb9e-ecd1-7059-99f3-f8bf47b63931", "status": "created", "checkout_url": "https://checkout.maytes.co/?id=019fbb9e-ecd1-7059-99f3-f8bf47b63931", "expires_at": null } } ``` The required fields are `total_amount`, `currency`, `return_url`, `cancel_url`, and at least one `item`. Optional fields include `merchant_order_id`, `merchant_expires_at`, `fees`, `discounts`, `tax`, `shipping_amount`, `customer_data`, and `metadata`. See the **[API reference](/api-reference)** for schemas and examples. ::: tip Amounts are minor units All monetary fields are integers in the currency's minor unit. `4500` = AUD 45.00. Never send decimals. ::: ### Checkout expiry * **`expires_at`** (response) — your `merchant_expires_at`, echoed in UTC, or `null` if you did not send one. The hosted page shows it to the buyer as the deadline. * Independently of that, an unpaid checkout is kept for **one hour** after creation. After whichever comes first, the checkout cannot be completed; create a new checkout for another attempt. * **`merchant_expires_at`** (optional request field, RFC 3339 datetime) — your order deadline. Use it for time-boxed sales such as ticket holds or reservation windows. Any offset is accepted (`2026-09-14T13:30:00+10:00` or `2026-09-14T03:30:00Z`); the offset is required. We always return it in UTC, e.g. `2026-09-14T03:30:00.000Z`. An uncaptured authorization is **automatically voided after 2 minutes**. See the [capture window](/capture-and-status#when-to-call-capture). *** ## The two redirect URLs Maytes stores `return_url` (success) and `cancel_url` (abandonment) verbatim and passes them to the Maytes window. We do **not** insert the `checkout_uuid`. ::: warning `return_url` is a confirmation page, not a fulfillment trigger In the [webhook flow](/how-it-works#the-webhook-flow), fulfill on capture success and use `return_url` only for the customer experience. Capture occurs before the customer leaves Maytes; they may remain in the split step or close the window without reaching `return_url`, but the payment still stands. See [when to capture](/capture-and-status#when-to-call-capture). ::: ### Correlating the return Include your `merchant_order_id` in the `return_url` query string so the success page can identify the order: ``` return_url: https://shop.example.com/thanks?order=order_1001 ``` When the customer lands on `/thanks?order=order_1001`, use `order_1001` to find the saved `checkout_uuid`. Then `GET` the checkout and branch on `status`; in the webhook flow, it will already be `captured`. *** ## How the customer reaches Maytes ### Option A — Maytes Checkout Button (recommended) Add the [Maytes Checkout Button](/checkout-button) to your page. On click, it calls your backend to create a checkout and opens the hosted checkout: in a centered popup with a full-screen overlay on wide screens (the default), or in the current tab on phones, when the browser blocks the popup, or with `mode: 'redirect'`. Return `checkout_uuid` and `checkout_url` from your backend; the button opens the URL you return. ### Option B — Redirect to `checkout_url` directly To avoid page-level JavaScript, redirect the customer's browser to the returned `checkout_url`: ```js res.redirect(created.data.checkout_url); ``` The customer pays in the Maytes-hosted window, then returns to your `return_url` or `cancel_url`. This option does not provide the popup and overlay experience. ### Option C — from your Stripe Payment Element If your checkout already runs on Stripe, offer **Split with Maytes** as a Stripe custom payment method. Selecting it hands control back to your page, which calls your backend to create the checkout and redirects to `checkout_url`. See [Maytes in your Stripe Payment Element](/stripe), or the simpler [express row](/stripe-express-checkout) placement. *** ## Amount rules Maytes recomputes the order from its items and compares the result with `total_amount`. A mismatch returns **`422 invalid_amount_arithmetic`**. The formula: ```text total_amount = Σ(item.unit_price × item.quantity) − Σ(discounts) + shipping_amount + Σ(fees) + tax (when not inclusive) ``` Send every amount as an integer in minor units. If your cart uses floats, round to whole minor units first. The `tax` object accepts an `amount` in minor units, an optional `percentage`, and an `inclusive` flag. When `inclusive` is true, tax is already included in item prices and does not increase the total. *** ## Line-item categories at a glance Every item has a `category`. Some categories require `custom_data`; for example, `ticket` requires `event_id`, `event_start_at`, and `timezone`: | `category` | Requires `custom_data` | Notes | | ---------------- | --------------------------------------------------------------- | ----------------------------- | | `ticket` | ✅ `event_id`, `event_start_at`, `timezone` (+ optional fields) | Events, gigs, sports | | `physical_goods` | ✅ `product_id` | Anything shipped | | `digital_goods` | optional | Downloads, in-game items | | `service` | optional | Bookings, time-based services | | `food_beverage` | — | F\&B orders | | `donation` | — | Charitable contributions | | `other` | — | Anything that doesn't fit | See the [API reference](/api-reference) for each category's request variant, required fields, and optional fields. ::: warning `custom_data` isn't in the SDK yet The SDK's `LineItem` does not yet include `custom_data`, so send `ticket` and `physical_goods` carts to `POST /api/merchant/v1/checkouts` as raw JSON. See the [API reference](/api-reference). You can still use the SDK for capture, cancel, status, and webhook verification. The SDK supports categories that do not require `custom_data`: `other`, `food_beverage`, `donation`, and `digital_goods` or `service` when no custom data is needed. ::: *** ## Common pitfalls ::: warning Don't recreate on conflict Retrying create for an order already in progress returns **`409 order_already_authorized`**. Do not create another checkout for the same `merchant_order_id`; retrieve the existing `checkout_uuid` from your store and continue with it. ::: | Pitfall | Fix | | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | | Amounts as floats / strings | Always integers, minor units. | | Cart currency doesn't match each item's currency | Every item must use the same `currency` as the cart total. | | Reusing `item_ref` across two items in the same cart | `item_ref` is unique within a cart. | | Forgetting `event_id` / `event_start_at` on `category: "ticket"` | These are required for ticket items; the API returns 422. | | Fulfillment wired to the `return_url` landing | In the webhook flow that landing may never happen. Fulfill on [capture](/capture-and-status). | | Treating `return_url` as templated | It isn't. Carry your own correlation key (see [above](#correlating-the-return)). | *** ## What's next * Wire up [Webhooks](/webhooks) — the `checkout.authorized` event that tells you to capture. * Open the Maytes window with the [Maytes Checkout Button](/checkout-button). * Finalize the order with [Capture & checkout status](/capture-and-status). * Browse every field and error in the [API reference](/api-reference). --- --- url: /webhooks.md --- # Webhooks Maytes notifies your backend of checkout state changes you can't observe synchronously. In the [webhook flow](/how-it-works#the-webhook-flow), `checkout.authorized` triggers capture, so your webhook endpoint is a required part of the integration. Deliveries are signed **HMAC-SHA256** POSTs to your configured HTTPS URL. Manage the subscription, signing secret, and delivery log in the **merchant portal** or through the [subscription API](#managing-your-subscription) using your merchant API OAuth credentials. *** ## Event types | Event | When it fires | What to do | | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | | `checkout.authorized` | The customer completed payment in the Maytes window; funds are held. | **Capture or cancel immediately.** The customer waits on a processing screen while you decide. | | `checkout.voided` | The authorization was released. `data.reason` is `merchant_cancel` (you canceled) or `auth_expired` (the hold lapsed before capture). | Release stock and mark the order unpaid. | | `marketing.opted_in` | A customer ticked your marketing opt-in box on a Maytes screen (once marketing opt-in is enabled for your store). Sent for **every** tick, including a repeat on the same order. | Add or update the contact in your marketing tool. See [Handling `marketing.opted_in`](#handling-marketing-opted-in). | | `webhook.test` | Sent only when you trigger a test event from the portal; real checkouts never fire it. | Verify your endpoint end-to-end before going live. | There is **no `checkout.captured` webhook**. Capture is a synchronous call you make, and its 200 response confirms success. There is also no refund webhook: after you [issue a refund](/refunds), observe the outcome through `GET /checkouts/{uuid}`. When subscribing, list the event types you want, or use `"*"` to receive everything. ## The envelope Every delivery has the same shape: ```json { "id": "evt_0192aa10-...", "type": "checkout.authorized", "api_version": "2026-06", "created_at": "2026-09-14T03:30:12.000Z", "data": { "checkout_uuid": "019fbb9e-ecd1-7059-99f3-f8bf47b63931", "merchant_order_id": "order_1001", "status": "authorized", "amount": 4500, "currency": "AUD", "user_uuid": "0192a7c1-..." } } ``` | Field | Meaning | | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | Stable event id, **unchanged across every retry**. Use it to deduplicate. | | `type` | The event type (see table above). | | `api_version` | Envelope schema version. Additive changes (new optional fields) do **not** bump it; breaking changes do. Ignore fields you don't recognize. | | `created_at` | When the event occurred. | | `data` | Per-event payload. | Payloads are intentionally minimal. `checkout.authorized` includes what you need to capture directly: `checkout_uuid`, `merchant_order_id`, `amount`, and `currency`. For the full state, call [`GET /checkouts/{uuid}`](/capture-and-status#reading-the-checkout-status). `checkout.voided` includes `checkout_uuid`, `merchant_order_id`, `status`, and `reason`. `marketing.opted_in` has its own payload, [described below](#handling-marketing-opted-in). *** ## Verify the signature Every delivery is signed. **Verify it with the [backend SDK](/build-your-integration) before trusting the request.** One call verifies the signature and returns the parsed event: ::: code-group ```ts [TypeScript] import express from 'express'; import { verifyWebhookSignature, WebhookSignatureError } from '@maytes/api-client-js'; // express.raw gives you the exact bytes we signed. Do NOT use express.json() here. app.post('/webhooks/maytes', express.raw({ type: 'application/json' }), async (req, res) => { try { const event = verifyWebhookSignature( req.body, // raw bytes req.header('x-maytes-signature'), process.env.MAYTES_WEBHOOK_SECRET, ); // Verified. Safe to act on — see the capture recipe below. await handleMaytesEvent(event); res.status(200).end(); } catch (err) { if (err instanceof WebhookSignatureError) { console.warn('rejected maytes webhook', err.reason); return res.status(400).end(); } throw err; } }); ``` ```python [Python] import os from flask import Flask, request from maytes_api_client import verify_webhook_signature, WebhookSignatureError @app.post("/webhooks/maytes") def maytes_webhook(): try: event = verify_webhook_signature( request.get_data(), # raw bytes request.headers.get("X-Maytes-Signature"), os.environ["MAYTES_WEBHOOK_SECRET"], ) except WebhookSignatureError as err: app.logger.warning("rejected maytes webhook: %s", err.reason) return "", 400 # Verified. Safe to act on — see the capture recipe below. handle_maytes_event(event) return "", 200 ``` ```php [PHP] getReason()); http_response_code(400); return; } // Verified. Safe to act on — see the capture recipe below. handleMaytesEvent($event); http_response_code(200); ``` ```go [Go] import maytes "github.com/maytes/api-client-go" func maytesWebhook(w http.ResponseWriter, r *http.Request) { rawBody, err := io.ReadAll(r.Body) if err != nil { w.WriteHeader(http.StatusBadRequest) return } event, err := maytes.VerifyWebhookSignature( rawBody, r.Header.Get("X-Maytes-Signature"), os.Getenv("MAYTES_WEBHOOK_SECRET"), ) if err != nil { log.Printf("rejected maytes webhook: %v", err) w.WriteHeader(http.StatusBadRequest) return } // Verified. Safe to act on — see the capture recipe below. handleMaytesEvent(event) w.WriteHeader(http.StatusOK) } ``` ```java [Java] import co.maytes.api.webhook.WebhookVerifier; byte[] rawBody = request.getInputStream().readAllBytes(); try { var event = WebhookVerifier.verify( rawBody, request.getHeader("X-Maytes-Signature"), System.getenv("MAYTES_WEBHOOK_SECRET")); // Verified. Safe to act on — see the capture recipe below. handleMaytesEvent(event); response.setStatus(200); } catch (WebhookVerifier.WebhookSignatureException e) { logger.warn("rejected maytes webhook: {}", e.getReason()); response.setStatus(400); } ``` ```ruby [Ruby] require 'maytes_api_client' post '/webhooks/maytes' do begin event = MaytesApiClient.verify_webhook_signature( request.body.read, # raw body request.env['HTTP_X_MAYTES_SIGNATURE'], ENV.fetch('MAYTES_WEBHOOK_SECRET'), ) rescue MaytesApiClient::WebhookSignatureError => e logger.warn("rejected maytes webhook: #{e.reason}") halt 400 end # Verified. Safe to act on — see the capture recipe below. handle_maytes_event(event) status 200 end ``` ```csharp [.NET] using MaytesApiClient.Webhooks; app.MapPost("/webhooks/maytes", async (HttpRequest request) => { using var buffer = new MemoryStream(); await request.Body.CopyToAsync(buffer); try { var evt = WebhookVerifier.Verify( buffer.ToArray(), // raw bytes request.Headers["X-Maytes-Signature"], Environment.GetEnvironmentVariable("MAYTES_WEBHOOK_SECRET")); // Verified. Safe to act on — see the capture recipe below. await HandleMaytesEventAsync(evt); return Results.Ok(); } catch (WebhookSignatureException e) { logger.LogWarning("rejected maytes webhook: {Reason}", e.Reason); return Results.BadRequest(); } }); ``` ::: The SDK handles four security-sensitive checks: | The SDK handles | What goes wrong without it | | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | | Hashes the **raw bytes** | Re-serializing JSON (`JSON.stringify(req.body)`) can change key order and number formatting, causing verification to fail. | | Accepts **any** matching `v1=` | During a [secret rotation](#rotating-your-signing-secret), the header carries two signatures. Reading only the first can reject valid traffic. | | **Constant-time** comparison | A normal `==` on digests leaks how much of a forged signature was right, one byte at a time. | | Rejects **stale** deliveries | Without a freshness check on `t`, a captured delivery stays replayable forever. | Follow three rules: 1. **Give it the raw body.** Use your framework's raw-body option (`express.raw`, `request.get_data()`, `php://input`) and keep JSON middleware off this route. The call returns the parsed event, so you never parse it yourself. 2. **Respond `400` and stop** when it throws. Don't log, queue, or retry the body. Treat the request as unverified. 3. **Keep the signing secret server-side**, in your secret manager alongside your OAuth credentials. It's shown **once**, when the subscription is created or rotated. Log the error's `reason` to distinguish `malformed_header`, `timestamp_out_of_tolerance` (clock skew or replay), `no_matching_signature` (wrong secret or modified body), and `invalid_json`. ::: details What the header looks like Deliveries include the signature in `X-Maytes-Signature` and repeat the timestamp in `X-Maytes-Timestamp`: ``` X-Maytes-Signature: t=1757820612,v1=5257a869e7...,v1=7a3f0e11bc... X-Maytes-Timestamp: 1757820612 ``` In the [delivery log](#the-delivery-log) or a request dump, two `v1` entries indicate a [secret rotation](#rotating-your-signing-secret) within its grace window. Let the SDK verify the header. We don't publish the signing algorithm because we can't update a deployed custom verifier when security issues arise, and verification failures can be silent. If we don't support your language, [tell us](/portal); we'll prioritize support instead of asking you to write a verifier. ::: ## Handling `checkout.authorized` 1. **Verify the signature** ([above](#verify-the-signature)). If it fails, respond `400` and stop. 2. **Deduplicate on `event.id`.** Delivery is [at-least-once](#delivery-semantics), and the id is stable across retries. This protects your side effects; the capture call is also [idempotent](/capture-and-status#idempotency-retries). 3. **Act on it.** Capture the payment, or cancel if you can't fulfill the order. 4. **Respond 2xx**, within the [15-second timeout](#timing-expectations). **[Capture & checkout status](/capture-and-status)** covers capture and cancel calls, capture failures, and the [complete handler in all seven languages](/capture-and-status#putting-it-together-your-webhook-handler). The customer waits on a processing screen during this flow. Keep pre-capture checks to fast lookups, and defer slow fulfillment work until after you respond. See [Timing expectations](#timing-expectations). ## Handling `marketing.opted_in` Once marketing opt-in is enabled for your store, Maytes shows your opt-in wording to the customer who placed the order (the lead) and to friends paying their share. Each time someone ticks your box, you receive `marketing.opted_in`. This is the only webhook that carries **personal data**, so store and handle it under your privacy policy. ```json { "id": "evt_0192ab77-...", "type": "marketing.opted_in", "api_version": "2026-06", "created_at": "2026-09-24T03:00:02.000Z", "data": { "customer_id": "6f1c2a3b-5c1e-5b9a-9d2f-3e4a5b6c7d8e", "subject": "lead", "order": { "merchant_order_id": "order_1001", "checkout_uuid": "019fbb9e-ecd1-7059-99f3-f8bf47b63931", "payment_request_uuid": null }, "customer": { "name": "Ada Lovelace", "email": "ada@example.com", "phone": "+61400000001" }, "opt_in": { "email_marketing": true, "sms_marketing": true, "opted_in_at": "2026-09-24T03:00:00.000Z", "placement": "success_page", "text_version": 3 } } } ``` | Field | Meaning | | ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `customer_id` | A stable id for this person **at your store**. Use it to match repeat opt-ins. The same person has a different `customer_id` at another merchant. | | `subject` | `lead` (the customer who placed the order) or `friend` (someone paying a share of it). | | `order` | The order the opt-in was given on. `payment_request_uuid` is set for a `friend`. It tells you **where** the opt-in happened, not whether the order was paid. Use `checkout.authorized` / `checkout.voided` for that. | | `customer.email` | The address the customer entered **with this opt-in**. A later opt-in from the same `customer_id` may carry a different address. The newest one is current. | | `customer.name`, `customer.phone` | From the customer's Maytes account at send time. `phone` is E.164 and is only included when the customer has verified it. Either may be `null`. | | `opt_in.email_marketing`, `opt_in.sms_marketing` | `true` for each channel we have a contact for, otherwise `null`. One tick covers both. | | `opt_in.opted_in_at` | When the customer ticked the box. | | `opt_in.placement` | Where the box was shown: `success_page`, `pay_link`, `app`, `email_link` or `other`. | | `opt_in.text_version` | The version of your opt-in wording the customer saw. Keep it with the contact as evidence of what they agreed to. | To handle it: 1. **Verify the signature** and **deduplicate on `event.id`**, as for every event. 2. **Upsert the contact** keyed on `customer_id`, using `customer.email` (and `customer.phone` for SMS if you use it). 3. **Respond 2xx.** There is no deadline beyond the 15-second timeout. If you fail, we keep retrying for about 3 days (see [Delivery semantics](#delivery-semantics)). After this, the relationship is yours. Unsubscribes happen in your own emails and tools, and Maytes doesn't need to be told. Every tick is sent, even from a customer who later unsubscribed from you. Treat a new `marketing.opted_in` as a fresh opt-in. ## Timing expectations The authorization is short-lived, and the customer waits on a payment-processing screen: | Stage | Expectation | | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Delivery of `checkout.authorized` | Sent within seconds of the customer authorizing. | | Your endpoint's response | Respond **2xx within 15 seconds** — the enforced per-attempt timeout. A verification + inline capture fits comfortably. | | **Your capture (or cancel)** | **Immediately, in the handler.** If it isn't captured, the authorization is **automatically voided after 2 minutes**. This window absorbs delivery retries and transient errors; it is not for deferred work. | | If the window closes | You receive [`checkout.voided`](#event-types) with `reason: "auth_expired"`. The customer's payment doesn't complete, the order remains unpaid, and later capture attempts return `409 not_capturable`. | Capture **inline in the handler**. Keep pre-capture stock and fraud checks fast, and defer emails and warehouse calls until after you respond. Run longer checks *before* creating the checkout. ## Delivery semantics * **At-least-once.** The same event can be delivered more than once; `id` is stable across retries. Deduplicate on it. * **No ordering guarantee.** Handle each event independently; don't assume `authorized` arrives before customer support sees the order. * **2xx means delivered.** Anything else is a failure, recorded in your delivery log: * **`checkout.authorized`** (time-boxed by the 2-minute authorization window): * **408 / 429 / 5xx / network errors / timeouts** → retried up to 3 times within the window, then parked for investigation. * **Other 4xx** → *not* retried. Your endpoint rejected a well-formed delivery, possibly because of a wrong path, broken verifier, or unexpected content-type assertion. Fix the endpoint and check the [delivery log](#the-delivery-log). * **`checkout.voided` and `marketing.opted_in`** aren't time-sensitive, so **any** non-2xx (including 4xx) is retried with increasing gaps for about **3 days** (9 attempts), with the same `id` each time. * **15-second timeout** per attempt. Respond promptly; do slow work after responding. * **Missed events**: your safety net is reconciliation — for any order stuck in an unexpected state, `GET /checkouts/{uuid}` gives you the current truth. ## Managing your subscription Manage the same subscription **in the merchant portal**, **over the API**, or with both. **In the portal** (access comes with [onboarding](/onboarding#_2-get-access-to-the-merchant-portal)), you can: * **Set your endpoint URL and event types.** * **Collect the signing secret** at creation, and **rotate** it later. * **Send a `webhook.test` event** to check your endpoint before real traffic depends on it. * **Read the [delivery log](#the-delivery-log)** for every attempt and returned HTTP status. Sending test events is portal-only. The delivery log is available in both the portal and API. **Over the API**, use your merchant OAuth credentials: | Operation | Endpoint | | -------------------------------------------------------- | ---------------------------------------------- | | Register or update your subscription (URL + event types) | `POST /api/merchant/v1/webhooks/subscription` | | Rotate the signing secret | `POST /api/merchant/v1/webhooks/secret/rotate` | | List recent delivery attempts | `GET /api/merchant/v1/webhooks/deliveries` | ```bash curl -s https://api.maytes.co/api/merchant/v1/webhooks/subscription \ -H "authorization: Bearer $ACCESS_TOKEN" \ -H 'content-type: application/json' \ -d '{ "url": "https://shop.example.com/webhooks/maytes", "event_types": ["checkout.authorized", "checkout.voided"] }' ``` Store the response's `signing_secret` immediately. It appears **only when the subscription is first created** and is not shown again. Updating the URL or event types doesn't change it. ### Rotating your signing secret To rotate without downtime: 1. Rotate in the portal or through the API endpoint above. The response carries the **new** secret. 2. For the next **2 hours**, every delivery is signed with **both** secrets — two `v1` entries in the header. Your verifier keeps matching on the old secret. 3. Deploy the new secret within the grace window. The old secret then stops being used automatically. For a suspected leak, pass `revoke_previous=true` to skip the grace window and revoke the old secret immediately. Deliveries may fail until you deploy the new secret. ### The delivery log `GET /api/merchant/v1/webhooks/deliveries` (also available in the portal) returns every attempt with `event_id`, `event_type`, `attempt_number`, `http_status`, `error_message`, and `duration_ms`. Paginate with `limit` (default 50, max 200) and `cursor`. ## Testing your endpoint 1. In the **sandbox environment**, subscribe your endpoint. For local development, expose it through a tunnel such as ngrok or cloudflared; public HTTPS deliveries can't reach `localhost` directly. 2. Send a **`webhook.test`** event from the portal. It bypasses your event-type filter and always arrives. Confirm signature verification passes and you respond 2xx. 3. Run a real test-card checkout end-to-end and confirm your handler captures. The [sandbox environment](/sandbox-environment) page has the card numbers and a full pre-go-live checklist. ## Endpoint requirements * **HTTPS**, publicly reachable. * Respond **2xx** within 15 seconds once you've verified and recorded the event. * Verify the signature on **every** request. The endpoint URL is guessable; the signature proves the delivery came from Maytes. ## What's next * [Quickstart](/quickstart) — wire the webhook flow end to end. * [Capture & checkout status](/capture-and-status) — everything about capture, cancel, and the status lifecycle. * [Security](/security) — credential and secret handling across the whole integration. --- --- url: /capture-and-status.md --- # Capture & checkout status Maytes uses an **authorize-then-capture** model. When the customer completes the Maytes window, their funds are *authorized* (held on the card), not yet taken. **Capture** to finalize the checkout and move the money, or **cancel** to release the authorization if you cannot fulfill. *** ## The merchant-side actions | Action | Endpoint | Purpose | |---|---|---| | **Get checkout** | [`GET /api/merchant/v1/checkouts/{checkout_uuid}`](/api-reference#api-1/tag/checkout-merchant/GET/api/merchant/v1/checkouts/{checkoutUuid}) | Read the current checkout state. | | **Capture** | [`POST /api/merchant/v1/checkouts/{checkout_uuid}/capture`](/api-reference#api-1/tag/checkout-merchant/POST/api/merchant/v1/checkouts/{checkoutUuid}/capture) | Finalize an authorized checkout; charge the customer. | | **Cancel** | [`POST /api/merchant/v1/checkouts/{checkout_uuid}/cancel`](/api-reference#api-1/tag/checkout-merchant/POST/api/merchant/v1/checkouts/{checkoutUuid}/cancel) | Void an authorized checkout; release the customer's hold. | See the **[API reference](/api-reference)** for complete request and response schemas. *** ## The checkout lifecycle ```mermaid flowchart LR created --> pending --> authorized authorized -->|capture| capturing --> captured authorized -->|cancel / expiry| voided pending -->|payment fails| failed captured -->|refund| refunding --> refunded ``` Handle the lifecycle in three phases: | Phase | What it means | Status values | What to do | |---|---|---|---| | **Awaiting payment** | Customer hasn't finished paying. | `created`, `pending` | Wait. | | **Authorized** | Funds are held on the customer's card. **Ready to capture.** | `authorized` | Capture (or cancel). | | **Done** | Terminal (or settling into terminal). | `captured` ✅, `voided`, `failed`, `refunding`, `refunded` | If `captured`, fulfill. Otherwise the order isn't paid (or has been [refunded](/refunds)). | The full status table: | Status | Meaning | |---|---| | `created` | Checkout created; customer hasn't paid yet. | | `pending` | Customer is in the Maytes window. | | `authorized` | Payment authorized (funds held). Ready to capture. | | `capturing` | Capture in flight. You won't see this as the response to your own `/capture` call — that call waits for the payment provider to confirm — but a concurrent `GET` can observe it. | | `captured` | Funds captured. Order is paid. ✅ | | `refunding` | Refund submitted; awaiting settlement. Normal interim state after [`/refund`](/refunds). | | `refunded` | Refund confirmed. Terminal. | | `voided` | Authorization released (you canceled, or the hold expired). | | `failed` | Payment failed. Terminal. | *** ## Reading the checkout status ::: tip You don't need this to learn the outcome In the [webhook flow](/webhooks), do not poll. The `checkout.authorized` event reports authorization, and the capture call returns the captured checkout synchronously. Read the checkout for: * **Reconciliation** — recover the current state when a delivery was missed or an order is in an unexpected state. * **Support tooling** — answer whether an order was paid. * **Refund state** — `refunded_amount` and `last_refund_at` change after you [issue a refund](/refunds). ::: Assumes a `maytes` client [constructed at startup](/build-your-integration#construct-the-client). ::: code-group ```ts [TypeScript] const checkout = await maytes.getCheckout({ checkoutUuid }); console.log(checkout.data.status); ``` ```python [Python] checkout = maytes.get_checkout(checkout_uuid) print(checkout.data.status) ``` ```php [PHP] $checkout = $maytes->getCheckout($checkoutUuid); echo $checkout->getData()->getStatus(); ``` ```go [Go] checkout, _, err := api.DefaultAPI.GetCheckout(ctx, checkoutUuid).Execute() if err != nil { /* handle */ } fmt.Println(checkout.Data.GetStatus()) ``` ```java [Java] GetCheckoutResponse checkout = maytes.getCheckout(checkoutUuid); System.out.println(checkout.getData().getStatus()); ``` ```ruby [Ruby] checkout = MAYTES.get_checkout(checkout_uuid) puts checkout.data.status ``` ```csharp [.NET] var checkout = await maytes.GetCheckoutAsync(checkoutUuid); Console.WriteLine(checkout.Data.Status); ``` ::: The response: ```json { "data": { "checkout_uuid": "019fbb9e-ecd1-7059-99f3-f8bf47b63931", "merchant_order_id": "order_1001", "status": "authorized", "amount": 4500, "currency": "AUD", "captured_at": null, "merchant_expires_at": null, "transaction_ref": null, "checkout_url": "https://checkout.maytes.co/?id=019fbb9e-ecd1-7059-99f3-f8bf47b63931", "return_url": "https://shop.example.com/thanks?order=order_1001", "cancel_url": "https://shop.example.com/cart", "service_fee": { "gross": 0, "discount": 0, "net": 0, "breakdown": [] }, "refunded_amount": 0, "merchant_processor_fee": 0, "merchant_service_fee": 0, "last_refund_at": null } } ``` Branch on `status`. On capture, `captured_at` changes from `null` to an ISO timestamp. `transaction_ref` is Maytes' transaction reference; it's filled in shortly after capture, so it can still be `null` right after. `service_fee` is the platform fee shown to the customer, distinct from your cart-level `fees`. The `refunded_amount` and `last_refund_at` fields track [refunds](/refunds). Optionally pass `merchant_order_id` as a query parameter when looking up a checkout from a stored UUID mapping. If it differs from the checkout's stored value, the request returns `422 order_mismatch`. *** ## Implement capture ### When to call capture Capture immediately from your **`checkout.authorized` webhook handler**. The customer remains on a processing screen until you respond, and the authorization is auto-voided after **2 minutes**. Capture success is your fulfillment trigger. See [timing expectations](/webhooks#timing-expectations) and [the assembled handler](#putting-it-together-your-webhook-handler). Capture is **server-side only** because it uses your OAuth Bearer token. ::: warning The capture window is 2 minutes Capture immediately after authorization. An uncaptured authorization is **automatically voided after 2 minutes**, releasing the customer's funds. If subscribed, you receive a [`checkout.voided` webhook](/webhooks#event-types) with `reason: "auth_expired"`; a late capture returns `409 not_capturable`. Use the window for retries, not to delay capture. You can set an order-level deadline with `merchant_expires_at` on [create](/create-checkout). ::: ### Capture the full amount Call `/capture` with an empty body to capture the full authorized amount. Include `expected_total_amount` and `currency` so Maytes can verify the checkout against your order: ::: code-group ```ts [TypeScript] const captured = await maytes.captureCheckout({ checkoutUuid, captureCheckoutRequest: { expectedTotalAmount: 4500, currency: 'AUD' }, }); // "captured" — the money has moved; fulfill now. console.log(captured.data.status); ``` ```python [Python] from maytes_api_client.models.capture_checkout_request import CaptureCheckoutRequest captured = maytes.capture_checkout(checkout_uuid, CaptureCheckoutRequest( expected_total_amount=4500, currency="AUD", )) # "captured" — the money has moved; fulfill now. print(captured.data.status) ``` ```php [PHP] use MaytesApiClient\Model\CaptureCheckoutRequest; $captured = $maytes->captureCheckout($checkoutUuid, new CaptureCheckoutRequest([ 'expected_total_amount' => 4500, 'currency' => 'AUD', ])); // "captured" — the money has moved; fulfill now. echo $captured->getData()->getStatus(); ``` ```go [Go] captureReq := *maytes.NewCaptureCheckoutRequest() captureReq.SetExpectedTotalAmount(4500) captureReq.SetCurrency("AUD") captured, _, err := api.DefaultAPI.CaptureCheckout(ctx, checkoutUuid). CaptureCheckoutRequest(captureReq).Execute() if err != nil { /* handle */ } // "captured" — the money has moved; fulfill now. fmt.Println(captured.Data.GetStatus()) ``` ```java [Java] CaptureCheckoutResponse captured = maytes.captureCheckout(checkoutUuid, new CaptureCheckoutRequest() .expectedTotalAmount(4500) .currency("AUD")); // "captured" — the money has moved; fulfill now. System.out.println(captured.getData().getStatus()); ``` ```ruby [Ruby] captured = MAYTES.capture_checkout(checkout_uuid, MaytesApiClient::CaptureCheckoutRequest.new( expected_total_amount: 4500, currency: 'AUD', ), ) # "captured" — the money has moved; fulfill now. puts captured.data.status ``` ```csharp [.NET] var captured = await maytes.CaptureCheckoutAsync(checkoutUuid, new CaptureCheckoutRequest { ExpectedTotalAmount = 4500, Currency = "AUD", }); // "captured" — the money has moved; fulfill now. Console.WriteLine(captured.Data.Status); ``` ::: | Field | Type | Notes | |---|---|---| | `expected_total_amount` | integer | Assertion. Mismatch → `422 amount_mismatch`. | | `currency` | string | Assertion. Mismatch → `422 currency_mismatch`. | | `merchant_order_id` | string | Assertion. Mismatch → `422 order_mismatch`. | | `metadata` | object | Echoed back. | An empty body (`{}`) captures the full authorized amount with no assertions. **Capture is synchronous.** The call waits for payment-provider confirmation, then returns `status: "captured"` and `captured_at`: ```json { "data": { "checkout_uuid": "019fbb9e-ecd1-7059-99f3-f8bf47b63931", "merchant_order_id": "order_1001", "status": "captured", "captured_at": "2026-09-14T03:31:12.000Z", "amount": 4500, "currency": "AUD" } } ``` A 200 response means the checkout is captured and ready for fulfillment. *** ## If you can't fulfill the order If you cannot fulfill, call **cancel** before capture. This releases the customer's authorization, moves the checkout to `voided`, and moves no funds. In the webhook flow, cancel promptly so the customer can leave the processing screen. ::: code-group ```ts [TypeScript] // The request body is optional — pass an empty object for a plain cancel. await maytes.cancelCheckout({ checkoutUuid, cancelCheckoutRequest: {} }); ``` ```python [Python] maytes.cancel_checkout(checkout_uuid) ``` ```php [PHP] $maytes->cancelCheckout($checkoutUuid); ``` ```go [Go] _, _, err := api.DefaultAPI.CancelCheckout(ctx, checkoutUuid).Execute() if err != nil { /* handle */ } ``` ```java [Java] // The request body is optional — pass null for a plain cancel. maytes.cancelCheckout(checkoutUuid, null); ``` ```ruby [Ruby] MAYTES.cancel_checkout(checkout_uuid) ``` ```csharp [.NET] await maytes.CancelCheckoutAsync(checkoutUuid); ``` ::: You can cancel only while the checkout is awaiting payment or **authorized**. After capture, the API returns **`409 session_not_voidable`**. Return captured funds through a [refund](/refunds). *** ## Putting it together: your webhook handler In the [webhook flow](/webhooks), your `checkout.authorized` handler deduplicates the event, runs final checks, captures or cancels, and then fulfills: `event` comes from [`verifyWebhookSignature`](/webhooks#verify-the-signature), and `maytes` is a client [constructed at startup](/build-your-integration#construct-the-client). You provide `alreadyProcessed`, `markProcessed`, and `orders`. ::: code-group ```ts [TypeScript] import { CaptureCheckoutCommand, CancelCheckoutCommand, MaytesWebhookEvent } from '@maytes/api-client-js'; async function handleCheckoutAuthorized(event: MaytesWebhookEvent) { // `data` values arrive as unknown — coerce at this boundary, once. const checkoutUuid = String(event.data.checkout_uuid); const merchantOrderId = String(event.data.merchant_order_id); const amount = Number(event.data.amount); const currency = String(event.data.currency); // 1. Dedupe — deliveries are at-least-once. if (await alreadyProcessed(event.id)) return; // 2. Your final checks (stock, fraud, order still open). const order = await orders.findByRef(merchantOrderId); if (!order || !order.isFulfillable()) { // Can't fulfill → release the hold so the customer isn't left waiting. await maytes.cancelCheckout({ checkoutUuid, cancelCheckoutRequest: {} }); return; } // 3. Capture — synchronous; 200 means the money moved. // The assertions guard against acting on the wrong order. await maytes.captureCheckout({ checkoutUuid, captureCheckoutRequest: { expectedTotalAmount: amount, currency, merchantOrderId, }, }); // 4. Fulfill. This is your trigger — not the return_url landing. await orders.markPaidAndFulfill(order, checkoutUuid); await markProcessed(event.id); } ``` ```python [Python] from maytes_api_client.models.capture_checkout_request import CaptureCheckoutRequest def handle_checkout_authorized(event): checkout_uuid = event.data["checkout_uuid"] merchant_order_id = event.data["merchant_order_id"] # 1. Dedupe — deliveries are at-least-once. if already_processed(event.id): return # 2. Your final checks (stock, fraud, order still open). order = orders.find_by_ref(merchant_order_id) if order is None or not order.is_fulfillable(): # Can't fulfill → release the hold so the customer isn't left waiting. maytes.cancel_checkout(checkout_uuid) return # 3. Capture — synchronous; 200 means the money moved. # The assertions guard against acting on the wrong order. maytes.capture_checkout(checkout_uuid, CaptureCheckoutRequest( expected_total_amount=event.data["amount"], currency=event.data["currency"], merchant_order_id=merchant_order_id, )) # 4. Fulfill. This is your trigger — not the return_url landing. orders.mark_paid_and_fulfill(order, checkout_uuid) mark_processed(event.id) ``` ```php [PHP] use MaytesApiClient\Model\CaptureCheckoutRequest; function handleCheckoutAuthorized(\MaytesApiClient\MaytesWebhookEvent $event): void { global $maytes, $orders; $checkoutUuid = $event->data['checkout_uuid']; $merchantOrderId = $event->data['merchant_order_id']; // 1. Dedupe — deliveries are at-least-once. if (alreadyProcessed($event->id)) { return; } // 2. Your final checks (stock, fraud, order still open). $order = $orders->findByRef($merchantOrderId); if ($order === null || !$order->isFulfillable()) { // Can't fulfill → release the hold so the customer isn't left waiting. $maytes->cancelCheckout($checkoutUuid); return; } // 3. Capture — synchronous; 200 means the money moved. // The assertions guard against acting on the wrong order. $maytes->captureCheckout($checkoutUuid, new CaptureCheckoutRequest([ 'expected_total_amount' => $event->data['amount'], 'currency' => $event->data['currency'], 'merchant_order_id' => $merchantOrderId, ])); // 4. Fulfill. This is your trigger — not the return_url landing. $orders->markPaidAndFulfill($order, $checkoutUuid); markProcessed($event->id); } ``` ```go [Go] func handleCheckoutAuthorized(ctx context.Context, event *maytes.MaytesWebhookEvent) error { checkoutUuid, _ := event.Data["checkout_uuid"].(string) merchantOrderID, _ := event.Data["merchant_order_id"].(string) // JSON numbers decode to float64 in a map[string]interface{}. amount, _ := event.Data["amount"].(float64) currency, _ := event.Data["currency"].(string) // 1. Dedupe — deliveries are at-least-once. if alreadyProcessed(event.ID) { return nil } // 2. Your final checks (stock, fraud, order still open). order, err := orders.FindByRef(merchantOrderID) if err != nil || !order.IsFulfillable() { // Can't fulfill → release the hold so the customer isn't left waiting. _, _, cancelErr := api.DefaultAPI.CancelCheckout(ctx, checkoutUuid).Execute() return cancelErr } // 3. Capture — synchronous; 200 means the money moved. // The assertions guard against acting on the wrong order. captureReq := *maytes.NewCaptureCheckoutRequest() captureReq.SetExpectedTotalAmount(int32(amount)) captureReq.SetCurrency(currency) captureReq.SetMerchantOrderId(merchantOrderID) if _, _, err := api.DefaultAPI.CaptureCheckout(ctx, checkoutUuid). CaptureCheckoutRequest(captureReq).Execute(); err != nil { return err } // 4. Fulfill. This is your trigger — not the return_url landing. if err := orders.MarkPaidAndFulfill(order, checkoutUuid); err != nil { return err } return markProcessed(event.ID) } ``` ```java [Java] import co.maytes.api.model.CaptureCheckoutRequest; import co.maytes.api.webhook.WebhookVerifier.MaytesWebhookEvent; void handleCheckoutAuthorized(MaytesWebhookEvent event) throws Exception { String checkoutUuid = (String) event.getData().get("checkout_uuid"); String merchantOrderId = (String) event.getData().get("merchant_order_id"); // JSON numbers arrive boxed — go via Number, not a direct Integer cast. int amount = ((Number) event.getData().get("amount")).intValue(); String currency = (String) event.getData().get("currency"); // 1. Dedupe — deliveries are at-least-once. if (alreadyProcessed(event.getId())) return; // 2. Your final checks (stock, fraud, order still open). Order order = orders.findByRef(merchantOrderId); if (order == null || !order.isFulfillable()) { // Can't fulfill → release the hold so the customer isn't left waiting. maytes.cancelCheckout(checkoutUuid, null); return; } // 3. Capture — synchronous; 200 means the money moved. // The assertions guard against acting on the wrong order. maytes.captureCheckout(checkoutUuid, new CaptureCheckoutRequest() .expectedTotalAmount(amount) .currency(currency) .merchantOrderId(merchantOrderId)); // 4. Fulfill. This is your trigger — not the return_url landing. orders.markPaidAndFulfill(order, checkoutUuid); markProcessed(event.getId()); } ``` ```ruby [Ruby] def handle_checkout_authorized(event) checkout_uuid = event.data['checkout_uuid'] merchant_order_id = event.data['merchant_order_id'] # 1. Dedupe — deliveries are at-least-once. return if already_processed?(event.id) # 2. Your final checks (stock, fraud, order still open). order = orders.find_by_ref(merchant_order_id) unless order&.fulfillable? # Can't fulfill → release the hold so the customer isn't left waiting. MAYTES.cancel_checkout(checkout_uuid) return end # 3. Capture — synchronous; 200 means the money moved. # The assertions guard against acting on the wrong order. MAYTES.capture_checkout(checkout_uuid, MaytesApiClient::CaptureCheckoutRequest.new( expected_total_amount: event.data['amount'], currency: event.data['currency'], merchant_order_id: merchant_order_id, ), ) # 4. Fulfill. This is your trigger — not the return_url landing. orders.mark_paid_and_fulfill(order, checkout_uuid) mark_processed(event.id) end ``` ```csharp [.NET] using MaytesApiClient.Model; using MaytesApiClient.Webhooks; async Task HandleCheckoutAuthorizedAsync(MaytesWebhookEvent evt) { // GetString() is nullable; these envelope fields are always present. var checkoutUuid = evt.Data["checkout_uuid"].GetString()!; var merchantOrderId = evt.Data["merchant_order_id"].GetString()!; // 1. Dedupe — deliveries are at-least-once. if (await AlreadyProcessedAsync(evt.Id)) return; // 2. Your final checks (stock, fraud, order still open). var order = await orders.FindByRefAsync(merchantOrderId); if (order is null || !order.IsFulfillable()) { // Can't fulfill → release the hold so the customer isn't left waiting. await maytes.CancelCheckoutAsync(checkoutUuid); return; } // 3. Capture — synchronous; 200 means the money moved. // The assertions guard against acting on the wrong order. await maytes.CaptureCheckoutAsync(checkoutUuid, new CaptureCheckoutRequest { ExpectedTotalAmount = evt.Data["amount"].GetInt32(), Currency = evt.Data["currency"].GetString()!, MerchantOrderId = merchantOrderId, }); // 4. Fulfill. This is your trigger — not the return_url landing. await orders.MarkPaidAndFulfillAsync(order, checkoutUuid); await MarkProcessedAsync(evt.Id); } ``` ::: Handle these edge cases: * **Capture returns `409 not_capturable`**: treat the order as unpaid. The checkout was already voided, usually because authorization expired before the retry. For a transient failure, retry once, then cancel so the customer does not remain on the processing screen. * **A duplicate delivery**: capture is idempotent (below), so a re-run won't double-charge. Deduping on `event.id` is what stops *your* fulfillment side effects running twice. * **Keep it inside the window**: deliveries time out after **15 seconds**, and authorization is voided after **2 minutes**. Verify and capture synchronously; queue emails and warehouse calls until after you respond. See [timing expectations](/webhooks#timing-expectations). *** ## Idempotency & retries Both **capture** and **cancel** are **idempotent on terminal states**. Capturing an already-captured checkout or canceling an already-voided checkout returns success without repeating the operation. You can retry after network failures. If capture is attempted twice, for example from a retried webhook delivery, the second call is a no-op success. Use the `merchant_order_id` set at create as your end-to-end deduplication key, and deduplicate webhook deliveries by envelope `id`. *** ## Common errors | HTTP | `code` | Operation | Cause | |---|---|---|---| | `409` | `not_capturable` | capture | The checkout isn't in a capturable state (e.g. not yet `authorized`, or already `voided`). | | `409` | `session_not_voidable` | cancel | The checkout can't be voided — typically already captured. | | `422` | `amount_mismatch` | capture | `expected_total_amount` didn't match the authorized amount. | | `422` | `currency_mismatch` | capture | `currency` didn't match. | | `422` | `order_mismatch` | get / capture / cancel | `merchant_order_id` didn't match. | | `422` | `no_active_session` | capture | The checkout has no active payment session to capture. | | `404` | `not_found` | all | No such checkout for your merchant account. | Full list per operation in the [API reference](/api-reference). *** ## Considerations * **Keep credentials server-side.** Capture and cancel use your OAuth Bearer token; never expose `client_id` or `client_secret` to the browser. * **Do not partially capture.** Partial capture is not supported. To fulfill only part of an order, cancel and create a new checkout for the smaller amount. *** ## What's next * [Webhooks](/webhooks) — the `checkout.authorized` event that triggers capture in the recommended flow. * [Create a checkout](/create-checkout) — the step that produces the `checkout_uuid` you capture here. * [API reference](/api-reference) — every field and error. --- --- url: /checkout-button.md --- # Maytes Checkout Button One button on your existing checkout. Your customer pays their share of the order on Maytes, and invites their mates to pay theirs. ## How it works 1. The customer clicks Split with Maytes on your page, and the button calls your `createCheckout`. 2. Your server creates the checkout and returns `checkout_uuid` and `checkout_url`. 3. The customer pays their share on Maytes, in a popup or the same tab. 4. Maytes sends `checkout.authorized` to your webhook, and your server captures. ::: details See the full sequence ```mermaid sequenceDiagram autonumber participant P as Your page, the button participant Y as Your server participant K as Maytes P->>Y: Customer clicks, button calls createCheckout() Y->>K: POST /api/merchant/v1/checkouts (OAuth) K-->>Y: checkout_uuid, checkout_url Y-->>P: the checkout id and URL P->>K: Button opens the hosted checkout (popup or same tab) K->>Y: Webhook: checkout.authorized Y->>K: POST /checkouts/{uuid}/capture K-->>P: Customer is sent to your return_url ``` ::: ## Choose how Maytes appears The Split with Maytes button can stand on its own or sit inside your Stripe checkout. Every placement creates the checkout on your server the same way. | Placement | Use it when | Guide | |---|---|---| | **On its own** | Maytes is a pay button of its own on your page | [Add the button](#add-the-button), below | | **In Stripe's express row** | Your checkout shows Apple Pay, Google Pay and Link, and Maytes goes in a full-width row under them | [Maytes in your Stripe express row](/stripe-express-checkout) | | **In Stripe's Payment Element** | Maytes is a payment method in your Stripe form, with the Maytes button in place of your Pay button | [Maytes in your Stripe Payment Element](/stripe) | ## Add the button Every stack calls your server through one small function. `createMaytesCheckout` asks your server to create the checkout and returns what the button needs: ```js async function createMaytesCheckout(cartId) { const res = await fetch('/api/maytes/create-checkout', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ cartId }), }); if (!res.ok) throw new Error('Could not create the Maytes checkout'); const { checkout_uuid, checkout_url } = await res.json(); return { checkoutId: checkout_uuid, checkoutUrl: checkout_url }; } ``` `/api/maytes/create-checkout` is a route on **your** server; name it anything. It calls Maytes's `POST /api/merchant/v1/checkouts` with your API credentials, which must never reach the browser. See [Create the checkout on your server](#create-the-checkout-on-your-server). Pick your stack: ::: code-group ```html [HTML]
``` ```ts [npm] // npm install @maytes/checkout-button import { Maytes } from '@maytes/checkout-button'; const maytes = Maytes({ environment: 'sandbox', createCheckout: () => createMaytesCheckout('cart_8fd21'), }); const slot = document.getElementById('maytes-button')!; maytes.renderButton(slot, { block: true }); // When the page goes away: maytes.destroy(); ``` ```tsx [React] // npm install @maytes/checkout-button import { MaytesProvider, MaytesButton, } from '@maytes/checkout-button/react'; export function SplitWithMaytes({ cartId }: { cartId: string }) { return ( createMaytesCheckout(cartId)} > ); } ``` ```tsx [Next.js] 'use client'; // createCheckout is a function, so this runs in the browser import { MaytesProvider, MaytesButton, } from '@maytes/checkout-button/react'; export function SplitWithMaytes({ cartId }: { cartId: string }) { return ( createMaytesCheckout(cartId)} > ); } ``` ```vue [Vue] ``` ```ts [Angular] import { AfterViewInit, Component, ElementRef, Input, OnDestroy, ViewChild, } from '@angular/core'; import { Maytes, type MaytesSDK } from '@maytes/checkout-button'; @Component({ selector: 'split-with-maytes', standalone: true, template: '
', }) export class SplitWithMaytesComponent implements AfterViewInit, OnDestroy { @Input() cartId = ''; @ViewChild('slot') slot!: ElementRef; private maytes?: MaytesSDK; ngAfterViewInit() { this.maytes = Maytes({ environment: 'sandbox', createCheckout: () => createMaytesCheckout(this.cartId), }); this.maytes.renderButton(this.slot.nativeElement, { block: true }); } ngOnDestroy() { this.maytes?.destroy(); } } ``` ```svelte [Svelte]
``` ::: ### Button options `renderButton(slot, options)` and `` take these options. All are optional. | Option | Default | What it does | |---|---|---| | `block` | `false` | Stretches the button to the full width of its container. | | `mode` | `'popup'` | `'popup'` opens Maytes in a window over your page; `'redirect'` opens it in the same tab. Phones always use the same tab. | | `label` | `'Split with'` | The text before the Maytes logo. | | `radius` | pill | Corner radius in pixels, 0 up to half the height. | | `height` | natural height | Height in pixels, 40 to 55. Keeps the label on one line. Use `{ radius: 6, height: 48 }` to match Apple Pay and Google Pay. | | `redirectOverlay` | `false` | Shows the Maytes loading screen while a same-tab checkout is created. | In React, pass them as props: ``. Events, `openCheckout()` and CSS custom properties are in the [API reference](https://github.com/kttipay/maytes-checkout-button/blob/main/docs/guides/api.md). The React components (`@maytes/checkout-button/react`) need version 1.2 or later. They work with React 18 and 19, StrictMode and server rendering, and from your own Pay button through `useMaytes()`: see the [React guide](https://github.com/kttipay/maytes-checkout-button/blob/main/docs/guides/react.md). Full setup guides for every stack, plus the button's options and events, are in the [SDK README](https://github.com/kttipay/maytes-checkout-button#readme). ## Create the checkout on your server ::: warning Keep credentials on your server Price the cart from your own catalogue and save `checkout_uuid` against the order before you respond. Your `client_id` and `client_secret` never reach the browser. ::: ::: code-group ```ts [TypeScript] import { createMaytesApiClient, ResponseError } from '@maytes/api-client-js'; const maytesApi = createMaytesApiClient({ endpoint: 'https://api.maytes.co', clientId: process.env.MAYTES_CLIENT_ID!, clientSecret: process.env.MAYTES_CLIENT_SECRET!, }); app.post('/api/maytes/create-checkout', async (req, res) => { const cart = await loadCart(req.body.cartId); const merchantOrderId = await createPendingOrder(cart); try { const created = await maytesApi.createCheckout({ createCheckoutRequest: { merchantOrderId, totalAmount: cart.total, // minor units currency: 'AUD', returnUrl: `https://shop.example.com/thanks?order=${merchantOrderId}`, cancelUrl: 'https://shop.example.com/cart', items: cart.lines.map((line) => ({ itemRef: line.sku, name: line.name, quantity: line.quantity, unitPrice: line.unitPrice, currency: 'AUD', category: 'other', })), }, }); await saveCheckoutUuid(merchantOrderId, created.data.checkoutUuid); res.json({ checkout_uuid: created.data.checkoutUuid, checkout_url: created.data.checkoutUrl }); } catch (err) { if (err instanceof ResponseError) return res.status(502).json({ error: 'checkout_create_failed' }); throw err; } }); ``` ```python [Python] from maytes_api_client.models.create_checkout_request import CreateCheckoutRequest from maytes_api_client.models.line_item import LineItem from maytes_api_client.models.other_line_item import OtherLineItem # In your POST /api/maytes/create-checkout handler, with `maytes` built at startup: created = maytes.create_checkout(CreateCheckoutRequest( merchant_order_id=order_id, total_amount=total, # minor units currency="AUD", return_url=f"https://shop.example.com/thanks?order={order_id}", cancel_url="https://shop.example.com/cart", items=[LineItem(OtherLineItem( item_ref="GA-1", name="General Admission", quantity=1, unit_price=total, currency="AUD", category="other", ))], )) save_checkout_uuid(order_id, created.data.checkout_uuid) return {"checkout_uuid": created.data.checkout_uuid, "checkout_url": created.data.checkout_url} ``` ```php [PHP] use MaytesApiClient\Model\CreateCheckoutRequest; use MaytesApiClient\Model\OtherLineItem; // In your POST /api/maytes/create-checkout endpoint, with $maytes built at startup: $created = $maytes->createCheckout(new CreateCheckoutRequest([ 'merchant_order_id' => $orderId, 'total_amount' => $total, // minor units 'currency' => 'AUD', 'return_url' => "https://shop.example.com/thanks?order={$orderId}", 'cancel_url' => 'https://shop.example.com/cart', 'items' => [new OtherLineItem([ 'item_ref' => 'GA-1', 'name' => 'General Admission', 'quantity' => 1, 'unit_price' => $total, 'currency' => 'AUD', 'category' => 'other', ])], ])); saveCheckoutUuid($orderId, $created->getData()->getCheckoutUuid()); echo json_encode([ 'checkout_uuid' => $created->getData()->getCheckoutUuid(), 'checkout_url' => $created->getData()->getCheckoutUrl(), ]); ``` ```go [Go] // In your POST /api/maytes/create-checkout handler, with `api` built at startup: item := maytes.OtherLineItemAsLineItem( maytes.NewOtherLineItem("GA-1", "General Admission", 1, total, "AUD", "other"), ) createReq := *maytes.NewCreateCheckoutRequest( total, "AUD", // minor units "https://shop.example.com/thanks?order="+orderID, "https://shop.example.com/cart", []maytes.LineItem{item}, ) createReq.SetMerchantOrderId(orderID) created, _, err := api.DefaultAPI.CreateCheckout(ctx).CreateCheckoutRequest(createReq).Execute() if err != nil { http.Error(w, "checkout_create_failed", http.StatusBadGateway) return } saveCheckoutUUID(orderID, created.Data.GetCheckoutUuid()) json.NewEncoder(w).Encode(map[string]string{ "checkout_uuid": created.Data.GetCheckoutUuid(), "checkout_url": created.Data.GetCheckoutUrl(), }) ``` ```java [Java] // In your POST /api/maytes/create-checkout controller, with `maytes` built at startup: CreateCheckoutResponse created = maytes.createCheckout( new CreateCheckoutRequest() .merchantOrderId(orderId) .totalAmount(total) // minor units .currency("AUD") .returnUrl(URI.create("https://shop.example.com/thanks?order=" + orderId)) .cancelUrl(URI.create("https://shop.example.com/cart")) .items(List.of(new LineItem(new OtherLineItem() .itemRef("GA-1").name("General Admission").quantity(1) .unitPrice(total).currency("AUD") .category(OtherLineItem.CategoryEnum.OTHER))))); saveCheckoutUuid(orderId, created.getData().getCheckoutUuid()); return Map.of( "checkout_uuid", created.getData().getCheckoutUuid(), "checkout_url", created.getData().getCheckoutUrl()); ``` ```ruby [Ruby] # In your POST /api/maytes/create-checkout handler, with MAYTES built at startup: created = MAYTES.create_checkout( MaytesApiClient::CreateCheckoutRequest.new( merchant_order_id: order_id, total_amount: total, # minor units currency: 'AUD', return_url: "https://shop.example.com/thanks?order=#{order_id}", cancel_url: 'https://shop.example.com/cart', items: [MaytesApiClient::OtherLineItem.new( item_ref: 'GA-1', name: 'General Admission', quantity: 1, unit_price: total, currency: 'AUD', category: 'other', )], ), ) save_checkout_uuid(order_id, created.data.checkout_uuid) { checkout_uuid: created.data.checkout_uuid, checkout_url: created.data.checkout_url }.to_json ``` ```csharp [.NET] // In your POST /api/maytes/create-checkout endpoint, with `maytes` built at startup: var created = await maytes.CreateCheckoutAsync(new CreateCheckoutRequest( totalAmount: total, // minor units currency: "AUD", returnUrl: $"https://shop.example.com/thanks?order={orderId}", cancelUrl: "https://shop.example.com/cart", items: new List { new LineItem(new OtherLineItem( itemRef: "GA-1", name: "General Admission", quantity: 1, unitPrice: total, currency: "AUD", category: OtherLineItem.CategoryEnum.Other)), }) { MerchantOrderId = orderId, }); await SaveCheckoutUuid(orderId, created.Data.CheckoutUuid); return Results.Json(new { checkout_uuid = created.Data.CheckoutUuid, checkout_url = created.Data.CheckoutUrl }); ``` ::: Every request field is on [Create a checkout](/create-checkout#a-minimal-create-request). ## Capture on the webhook Your page never learns the payment result. When Maytes sends [`checkout.authorized`](/webhooks), [capture](/capture-and-status#when-to-call-capture) within 2 minutes, then show the order on your `return_url` page. ## Go live | `environment` | Opens | Use with | |---|---|---| | `sandbox` | The [sandbox](/sandbox-environment) checkout | Sandbox API credentials | | `production` | The live checkout | Production credentials | 1. Set the button to `environment: 'production'`. 2. Point your server at your production credentials. 3. Subscribe your production webhook, and capture on it. The button's environment must match your server's. Return the `checkout_url` Maytes gives you; never build one. ## Other ways to send customers to Maytes | Instead of the button | Use when | |---|---| | [Redirect to `checkout_url`](/create-checkout#option-b-—-redirect-to-checkout-url-directly) | You don't want JavaScript on the page | Something wrong with the button? Open an issue on [the SDK repository](https://github.com/kttipay/maytes-checkout-button/issues), or email support@maytes.co. --- --- url: /stripe-express-checkout.md --- # Add Maytes to your Stripe express row Put **Split with Maytes** in your express row, in a full-width row of its own under Apple Pay, Google Pay and Link. It's our button: nothing changes in your Stripe account or your Stripe payment code. ::: tip Is this the right page? Use it to put Maytes under Stripe's wallet buttons. To offer Maytes as a payment method inside Stripe's payment form, see [Maytes in your Stripe Payment Element](/stripe). You can do both. ::: The Maytes Checkout Button sits in a full-width row of its own directly under Apple Pay, Google Pay and Link, matched to their shape with `renderButton(slot, { block: true, height: 48, radius: 6 })`. **You add two things:** a slot under Stripe's Express Checkout Element, and the Maytes button in it. Your server needs the standard Maytes endpoint and webhook from the [checkout button guide](/checkout-button#create-the-checkout-on-your-server). ## 1. Make room in the express row Stripe draws its wallets inside its own iframe, so Maytes goes under the Express Checkout Element, not inside it. Stripe's wallet row changes height with the wallets a browser offers, so give Maytes a row of its own instead of a cell beside it: ```html
``` ```css .express-row { display: grid; gap: 8px; } ``` Mount Stripe's element in the first row, with its buttons 48px tall. Leave Stripe's `layout` at its default, because the number of wallets and rows changes from browser to browser: ```js const express = elements.create('expressCheckout', { buttonHeight: 48 }); express.mount('#stripe-express'); ``` ## 2. Add the Maytes button Render the button in the second row. Three options match it to the wallets: `block` fills the row, `height: 48` matches Stripe's `buttonHeight`, and `radius: 6` matches Stripe's default corners. ::: code-group ```html [HTML] ``` ```ts [npm] // npm install @maytes/checkout-button import { Maytes } from '@maytes/checkout-button'; const maytes = Maytes({ environment: 'sandbox', createCheckout: () => createMaytesCheckout('cart_8fd21'), }); const slot = document.getElementById('maytes-button')!; maytes.renderButton(slot, { block: true, height: 48, radius: 6 }); ``` ```tsx [React] // npm install @maytes/checkout-button import { ExpressCheckoutElement } from '@stripe/react-stripe-js'; import { MaytesProvider, MaytesButton, } from '@maytes/checkout-button/react'; // Render inside your provider. export function ExpressRow({ cartId }: { cartId: string }) { return (
createMaytesCheckout(cartId)} >
); } ``` ```tsx [Next.js] 'use client'; // Stripe and Maytes both run in the browser import { ExpressCheckoutElement } from '@stripe/react-stripe-js'; import { MaytesProvider, MaytesButton, } from '@maytes/checkout-button/react'; // Render inside your provider. export function ExpressRow({ cartId }: { cartId: string }) { return (
createMaytesCheckout(cartId)} >
); } ``` ```vue [Vue] ``` ```ts [Angular] import { AfterViewInit, Component, ElementRef, Input, OnDestroy, ViewChild, } from '@angular/core'; import { Maytes, type MaytesSDK } from '@maytes/checkout-button'; @Component({ selector: 'maytes-cell', standalone: true, template: '
', }) export class MaytesCellComponent implements AfterViewInit, OnDestroy { @Input() cartId = ''; @ViewChild('slot') slot!: ElementRef; private maytes?: MaytesSDK; ngAfterViewInit() { this.maytes = Maytes({ environment: 'sandbox', createCheckout: () => createMaytesCheckout(this.cartId), }); this.maytes.renderButton(this.slot.nativeElement, { block: true, height: 48, radius: 6, }); } ngOnDestroy() { this.maytes?.destroy(); } } ``` ```svelte [Svelte]
``` ::: In Vue, Angular and Svelte, the component above is the Maytes row: place it directly under Stripe's element. ::: details Where `createMaytesCheckout` comes from It's a small function on your page that asks your server for the checkout: ```js async function createMaytesCheckout(cartId) { const res = await fetch('/api/maytes/create-checkout', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ cartId }), }); if (!res.ok) throw new Error('Could not create the Maytes checkout'); const { checkout_uuid, checkout_url } = await res.json(); return { checkoutId: checkout_uuid, checkoutUrl: checkout_url }; } ``` `/api/maytes/create-checkout` is a route on your server. It calls Maytes's `POST /api/merchant/v1/checkouts` with your credentials: see [Create the checkout on your server](/checkout-button#create-the-checkout-on-your-server). ::: ## Test and go live * \[ ] Keep the button on `environment: 'sandbox'`, point your server at the [sandbox](/sandbox-environment), and pay with a [test card](/sandbox-environment#test-cards). * \[ ] Check the row at phone width and with every wallet your browsers offer, such as Apple Pay in Safari and Google Pay in Chrome. Maytes stays full width under them. * \[ ] Switch to `environment: 'production'` with production credentials, then follow the [go-live checklist](/checkout-button#go-live). ## Troubleshooting | What you see | What to do | |---|---| | Maytes is taller or rounder than the wallets | Match `height` to Stripe's `buttonHeight`, and `radius` to your Stripe appearance's `borderRadius` | | Maytes floats between Stripe's rows, or sits beside them | Put Maytes in its own full-width row under Stripe's element, as in [step 1](#_1-make-room-in-the-express-row). Stripe's wallet row changes height with the wallets a browser offers, so a cell beside it can misalign | | The order is authorized but never captured | Your webhook must capture within 2 minutes: see [Capture on the webhook](/checkout-button#capture-on-the-webhook) | ## Record Maytes payments in Stripe A Maytes order doesn't appear in your Stripe Dashboard on its own. To show it there with a Stripe receipt, see [Record Maytes payments in Stripe](/stripe-payment-records). ## Good to know * **Stripe never sees a Maytes order:** no PaymentIntent, no Stripe fee, and nothing in your Stripe Dashboard unless you [record it](/stripe-payment-records). Money arrives through [Maytes settlements](/portal); refunds go through [Maytes refunds](/refunds). * **Maytes returns no shipping address:** show the button where your page already knows the final total. --- --- url: /stripe.md --- # Add Maytes to your Stripe Payment Element Show **Split with Maytes** as a payment method inside Stripe's Payment Element. When the customer picks it, the Maytes button takes the place of your Pay button and opens the Maytes checkout. Stripe takes no payment for that order. ::: tip Is this the right page? Use it to offer Maytes as a payment method inside Stripe's payment form. To put Maytes under Apple Pay and Google Pay instead, see [Maytes in your Stripe express row](/stripe-express-checkout): it's less work, with nothing to set up in Stripe. You can do both. ::: Split with Maytes appears as a payment method in the Payment Element. While it is selected, the Maytes Checkout Button is shown in place of the merchant's Pay button and opens the Maytes checkout. **You add three things:** Split with Maytes in your Stripe Dashboard, one entry in your Elements options, and the Maytes button where your Pay button goes. Your server needs the standard Maytes endpoint and webhook from the [checkout button guide](/checkout-button#create-the-checkout-on-your-server). ## 1. Create Split with Maytes in Stripe Stripe only shows a custom payment method to the account that created it. Maytes cannot create it for you, so you add it to your own Stripe account once per environment. 1. In your Stripe Dashboard, open **Settings → Payments → Custom payment methods** and click **Create**. 2. Name it exactly **`Split with Maytes`**. 3. Upload this logo: [Download the logo](/stripe/split-with-maytes-logo.png){download} (PNG, 1200 × 375). 4. Copy the `cpmt_…` ID Stripe shows next to it. That is your `MAYTES_CPM_ID` for step 2. ![Split with Maytes listed under Custom payment methods, with its cpmt\_ ID](/stripe/custom-payment-method-created.png) Do this in test mode and again in live mode. Each mode gets its own ID, so keep each `cpmt_…` beside the publishable key from the same mode. ::: warning A mismatched key and ID fail silently If the `cpmt_` ID comes from a different Stripe account or mode than your publishable key, Stripe drops it: the Payment Element just shows no Split with Maytes. The only sign is a browser console warning, `The following custom payment method types are not enabled`. ::: ## 2. Add it to your Payment Element Add your `cpmt_` ID to `customPaymentMethods`. Elements must be in deferred mode: `mode`, `amount` and `currency`, with no client secret. ```js const elements = stripe.elements({ mode: 'payment', amount: 4500, // your cart total in minor units: AUD 45.00 currency: 'aud', customPaymentMethods: [ { id: MAYTES_CPM_ID, // your cpmt_ ID for this Stripe mode payment: { type: 'static', subtitle: 'Split the cost with friends', }, }, ], }); const paymentElement = elements.create('payment'); paymentElement.mount('#payment-element'); ``` Stripe lists custom payment methods last. To show Split with Maytes first, pass `paymentMethodOrder: [MAYTES_CPM_ID, 'card']` to `elements.create('payment', …)`. In React, pass the same options to ``. In TypeScript, cast the Maytes entry once: Stripe reads `payment`, but `@stripe/stripe-js` 10 still types the field as `options`. ```ts import type { CustomPaymentMethod } from '@stripe/stripe-js'; const maytesMethod = { id: MAYTES_CPM_ID, payment: { type: 'static', subtitle: 'Split the cost with friends' }, } as unknown as CustomPaymentMethod; ``` ## 3. Add the Maytes button Show the Maytes button in place of your Pay button while Split with Maytes is selected. The Payment Element's `change` event tells you which method is selected. Your Pay button is the one you already have for cards, shown here calling `payWithStripe()`; it doesn't change. The only Maytes part is the Maytes button, which opens the checkout itself. ::: code-group ```html [HTML] ``` ```ts [npm] // npm install @maytes/checkout-button import { Maytes } from '@maytes/checkout-button'; const maytes = Maytes({ environment: 'sandbox', createCheckout: () => createMaytesCheckout('cart_8fd21'), }); const payButton = document.getElementById('pay')!; const maytesSlot = document.getElementById('maytes-button')!; maytes.renderButton(maytesSlot, { block: true }); payButton.addEventListener('click', payWithStripe); paymentElement.on('change', (event) => { const maytesSelected = event.value.type === MAYTES_CPM_ID; payButton.hidden = maytesSelected; maytesSlot.hidden = !maytesSelected; }); ``` ```tsx [React] // npm install @maytes/checkout-button import { useState } from 'react'; import { PaymentElement } from '@stripe/react-stripe-js'; import { MaytesProvider, MaytesButton, } from '@maytes/checkout-button/react'; // Render inside your provider from step 2. export function Payment({ cartId }: { cartId: string }) { const [method, setMethod] = useState(''); return ( createMaytesCheckout(cartId)} > setMethod(event.value.type)} /> {method === MAYTES_CPM_ID ? ( ) : ( )} ); } ``` ```tsx [Next.js] 'use client'; // Stripe and Maytes both run in the browser import { useState } from 'react'; import { PaymentElement } from '@stripe/react-stripe-js'; import { MaytesProvider, MaytesButton, } from '@maytes/checkout-button/react'; // Render inside your provider from step 2. export function Payment({ cartId }: { cartId: string }) { const [method, setMethod] = useState(''); return ( createMaytesCheckout(cartId)} > setMethod(event.value.type)} /> {method === MAYTES_CPM_ID ? ( ) : ( )} ); } ``` ```vue [Vue] ``` ```ts [Angular] import { AfterViewInit, Component, ElementRef, Input, OnDestroy, ViewChild, signal, } from '@angular/core'; import { Maytes, type MaytesSDK } from '@maytes/checkout-button'; @Component({ selector: 'pay-buttons', standalone: true, template: `
`, }) export class PayButtonsComponent implements AfterViewInit, OnDestroy { @Input() cartId = ''; @ViewChild('slot') slot!: ElementRef; protected readonly maytesSelected = signal(false); private maytes?: MaytesSDK; ngAfterViewInit() { this.maytes = Maytes({ environment: 'sandbox', createCheckout: () => createMaytesCheckout(this.cartId), }); this.maytes.renderButton(this.slot.nativeElement, { block: true }); paymentElement.on('change', (event) => { this.maytesSelected.set(event.value.type === MAYTES_CPM_ID); }); } pay() { payWithStripe(); } ngOnDestroy() { this.maytes?.destroy(); } } ``` ```svelte [Svelte] ``` ::: `paymentElement` is the element from [step 2](#_2-add-it-to-your-payment-element). `block` makes the Maytes button full width; add `height` and `radius` to match your Pay button ([Button options](/checkout-button#button-options)). Don't call `stripe.confirmPayment()` for Maytes: there's no PaymentIntent to confirm. ::: details Where `createMaytesCheckout` comes from It's a small function on your page that asks your server for the checkout: ```js async function createMaytesCheckout(cartId) { const res = await fetch('/api/maytes/create-checkout', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ cartId }), }); if (!res.ok) throw new Error('Could not create the Maytes checkout'); const { checkout_uuid, checkout_url } = await res.json(); return { checkoutId: checkout_uuid, checkoutUrl: checkout_url }; } ``` `/api/maytes/create-checkout` is a route on your server. It calls Maytes's `POST /api/merchant/v1/checkouts` with your credentials: see [Create the checkout on your server](/checkout-button#create-the-checkout-on-your-server). ::: ## Test and go live * \[ ] With your `pk_test_` key and your test `cpmt_` ID, check that picking Split with Maytes shows the Maytes button, and that it opens Maytes. * \[ ] Pay one order with Maytes, using a [sandbox test card](/sandbox-environment#test-cards), and one with a card through Stripe. * \[ ] Go live with your `pk_live_` key and your live-mode `cpmt_` ID, then follow the [go-live checklist](/checkout-button#go-live). ## Troubleshooting | What you see | What to do | |---|---| | Split with Maytes doesn't appear, console says `custom payment method types are not enabled` | Use the `cpmt_` ID from your own Stripe account, in the same environment as your publishable key | | Elements complains about a missing client secret | Use deferred mode: `mode`, `amount` and `currency`, with no client secret | | Your Pay button still shows beside the Maytes button | Your CSS sets `display` on it, which overrides `hidden`: add `[hidden] { display: none !important; }` | | The order is authorized but never captured | Your webhook must capture within 2 minutes: see [Capture on the webhook](/checkout-button#capture-on-the-webhook) | ## Record Maytes payments in Stripe A Maytes order doesn't appear in your Stripe Dashboard on its own. To show it there with a Stripe receipt, see [Record Maytes payments in Stripe](/stripe-payment-records). ## Good to know * **Stripe doesn't process a Maytes order:** there's no PaymentIntent. Money arrives through [Maytes settlements](/portal); refunds go through [Maytes refunds](/refunds), never Stripe. * **Send a cart reference, not a total:** your server prices the cart from your own catalogue. See [Amount rules](/create-checkout#amount-rules). * **Keep Stripe and Maytes webhooks on separate routes:** they use different signing secrets. --- --- url: /stripe-payment-records.md --- # Record Maytes payments in Stripe A Maytes order doesn't appear in your Stripe Dashboard, because Stripe takes no payment for it. After you capture, add the order to Stripe as an invoice paid outside Stripe. It then shows in your Stripe Dashboard, and your customer can download a Stripe receipt. ::: tip No money moves through Stripe You're still paid through [Maytes settlements](/portal). ::: ## What you'll see in Stripe * **Invoices:** a **Paid** invoice for the order. * **Payments:** a payment shown as **Paid out of band**, plus a canceled **Payment for Invoice**. * **Receipt:** **Paid with: Split with Maytes** in its header. Stripe creates the **Payment for Invoice** when you finalize the invoice, then cancels it when you mark the invoice paid. It moves no money, and you can't avoid it. Each customer also gets a Maytes receipt for their own share. ## What you need Do this from your server, right after [capture](/capture-and-status#capture-the-full-amount) returns `status: "captured"`. The examples use these values: | In the examples | Where it comes from | |---|---| | `amount` | The capture response's `amount`, in minor units: `4500` is AUD 45.00 | | `currency` | The capture response's `currency`, in lowercase: `AUD` becomes `aud` | | `orderId` | The capture response's `merchant_order_id`, or your own order number if you didn't send one | | `checkoutUuid` | The capture response's `checkout_uuid`, kept in the invoice's metadata so you can match it to the Maytes order | Python and Ruby use `order_id` and `checkout_uuid`. Your Stripe secret key is read from `STRIPE_SECRET_KEY`. ## 1. Create the customer Create a Stripe customer, or use one you already have. A receipt needs the customer's email address. ::: code-group ```ts [TypeScript] import Stripe from 'stripe'; const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); const customer = await stripe.customers.create({ name: 'Customer Name', email: 'customer@example.com', }); ``` ```python [Python] import os from stripe import StripeClient stripe = StripeClient(os.environ["STRIPE_SECRET_KEY"]) customer = stripe.v1.customers.create({ "name": "Customer Name", "email": "customer@example.com", }) ``` ```php [PHP] $stripe = new \Stripe\StripeClient(getenv('STRIPE_SECRET_KEY')); $customer = $stripe->customers->create([ 'name' => 'Customer Name', 'email' => 'customer@example.com', ]); ``` ```go [Go] sc := stripe.NewClient(os.Getenv("STRIPE_SECRET_KEY")) customer, err := sc.V1Customers.Create(ctx, &stripe.CustomerCreateParams{ Name: stripe.String("Customer Name"), Email: stripe.String("customer@example.com"), }) if err != nil { /* handle */ } ``` ```java [Java] StripeClient stripe = new StripeClient(System.getenv("STRIPE_SECRET_KEY")); Customer customer = stripe.v1().customers().create( CustomerCreateParams.builder() .setName("Customer Name") .setEmail("customer@example.com") .build()); ``` ```ruby [Ruby] stripe = Stripe::StripeClient.new(ENV.fetch('STRIPE_SECRET_KEY')) customer = stripe.v1.customers.create({ name: 'Customer Name', email: 'customer@example.com', }) ``` ```csharp [.NET] var stripe = new StripeClient(Environment.GetEnvironmentVariable("STRIPE_SECRET_KEY")); var customer = await stripe.V1.Customers.CreateAsync(new CustomerCreateOptions { Name = "Customer Name", Email = "customer@example.com", }); ``` ::: ## 2. Create the receipt Stripe makes receipts from paid invoices. Create an invoice for the order, finalize it, then mark it as paid outside Stripe. ::: code-group ```ts [TypeScript] const invoice = await stripe.invoices.create({ customer: customer.id, currency, collection_method: 'send_invoice', days_until_due: 0, auto_advance: false, description: `Order ${orderId}`, custom_fields: [{ name: 'Paid with', value: 'Split with Maytes' }], metadata: { maytes_checkout_uuid: checkoutUuid }, }); await stripe.invoiceItems.create({ customer: customer.id, invoice: invoice.id, amount, currency, description: `Order ${orderId}`, }); await stripe.invoices.finalizeInvoice(invoice.id, { auto_advance: false }); await stripe.invoices.pay(invoice.id, { paid_out_of_band: true }); ``` ```python [Python] invoice = stripe.v1.invoices.create({ "customer": customer.id, "currency": currency, "collection_method": "send_invoice", "days_until_due": 0, "auto_advance": False, "description": f"Order {order_id}", "custom_fields": [{"name": "Paid with", "value": "Split with Maytes"}], "metadata": {"maytes_checkout_uuid": checkout_uuid}, }) stripe.v1.invoice_items.create({ "customer": customer.id, "invoice": invoice.id, "amount": amount, "currency": currency, "description": f"Order {order_id}", }) stripe.v1.invoices.finalize_invoice(invoice.id, {"auto_advance": False}) stripe.v1.invoices.pay(invoice.id, {"paid_out_of_band": True}) ``` ```php [PHP] $invoice = $stripe->invoices->create([ 'customer' => $customer->id, 'currency' => $currency, 'collection_method' => 'send_invoice', 'days_until_due' => 0, 'auto_advance' => false, 'description' => "Order {$orderId}", 'custom_fields' => [['name' => 'Paid with', 'value' => 'Split with Maytes']], 'metadata' => ['maytes_checkout_uuid' => $checkoutUuid], ]); $stripe->invoiceItems->create([ 'customer' => $customer->id, 'invoice' => $invoice->id, 'amount' => $amount, 'currency' => $currency, 'description' => "Order {$orderId}", ]); $stripe->invoices->finalizeInvoice($invoice->id, ['auto_advance' => false]); $stripe->invoices->pay($invoice->id, ['paid_out_of_band' => true]); ``` ```go [Go] invoice, err := sc.V1Invoices.Create(ctx, &stripe.InvoiceCreateParams{ Customer: stripe.String(customer.ID), Currency: stripe.String(currency), CollectionMethod: stripe.String("send_invoice"), DaysUntilDue: stripe.Int64(0), AutoAdvance: stripe.Bool(false), Description: stripe.String("Order " + orderId), CustomFields: []*stripe.InvoiceCreateCustomFieldParams{ {Name: stripe.String("Paid with"), Value: stripe.String("Split with Maytes")}, }, Metadata: map[string]string{"maytes_checkout_uuid": checkoutUuid}, }) if err != nil { /* handle */ } _, err = sc.V1InvoiceItems.Create(ctx, &stripe.InvoiceItemCreateParams{ Customer: stripe.String(customer.ID), Invoice: stripe.String(invoice.ID), Amount: stripe.Int64(amount), Currency: stripe.String(currency), Description: stripe.String("Order " + orderId), }) if err != nil { /* handle */ } _, err = sc.V1Invoices.FinalizeInvoice(ctx, invoice.ID, &stripe.InvoiceFinalizeInvoiceParams{ AutoAdvance: stripe.Bool(false), }) if err != nil { /* handle */ } _, err = sc.V1Invoices.Pay(ctx, invoice.ID, &stripe.InvoicePayParams{ PaidOutOfBand: stripe.Bool(true), }) if err != nil { /* handle */ } ``` ```java [Java] Invoice invoice = stripe.v1().invoices().create( InvoiceCreateParams.builder() .setCustomer(customer.getId()) .setCurrency(currency) .setCollectionMethod(InvoiceCreateParams.CollectionMethod.SEND_INVOICE) .setDaysUntilDue(0L) .setAutoAdvance(false) .setDescription("Order " + orderId) .addCustomField(InvoiceCreateParams.CustomField.builder() .setName("Paid with") .setValue("Split with Maytes") .build()) .putMetadata("maytes_checkout_uuid", checkoutUuid) .build()); stripe.v1().invoiceItems().create( InvoiceItemCreateParams.builder() .setCustomer(customer.getId()) .setInvoice(invoice.getId()) .setAmount(amount) .setCurrency(currency) .setDescription("Order " + orderId) .build()); stripe.v1().invoices().finalizeInvoice(invoice.getId(), InvoiceFinalizeInvoiceParams.builder().setAutoAdvance(false).build()); stripe.v1().invoices().pay(invoice.getId(), InvoicePayParams.builder().setPaidOutOfBand(true).build()); ``` ```ruby [Ruby] invoice = stripe.v1.invoices.create({ customer: customer.id, currency: currency, collection_method: 'send_invoice', days_until_due: 0, auto_advance: false, description: "Order #{order_id}", custom_fields: [{ name: 'Paid with', value: 'Split with Maytes' }], metadata: { maytes_checkout_uuid: checkout_uuid }, }) stripe.v1.invoice_items.create({ customer: customer.id, invoice: invoice.id, amount: amount, currency: currency, description: "Order #{order_id}", }) stripe.v1.invoices.finalize_invoice(invoice.id, { auto_advance: false }) stripe.v1.invoices.pay(invoice.id, { paid_out_of_band: true }) ``` ```csharp [.NET] var invoice = await stripe.V1.Invoices.CreateAsync(new InvoiceCreateOptions { Customer = customer.Id, Currency = currency, CollectionMethod = "send_invoice", DaysUntilDue = 0, AutoAdvance = false, Description = $"Order {orderId}", CustomFields = new List { new InvoiceCustomFieldOptions { Name = "Paid with", Value = "Split with Maytes" }, }, Metadata = new Dictionary { ["maytes_checkout_uuid"] = checkoutUuid }, }); await stripe.V1.InvoiceItems.CreateAsync(new InvoiceItemCreateOptions { Customer = customer.Id, Invoice = invoice.Id, Amount = amount, Currency = currency, Description = $"Order {orderId}", }); await stripe.V1.Invoices.FinalizeInvoiceAsync(invoice.Id, new InvoiceFinalizeOptions { AutoAdvance = false, }); await stripe.V1.Invoices.PayAsync(invoice.Id, new InvoicePayOptions { PaidOutOfBand = true, }); ``` ::: Save the invoice ID with your order for step 3. `auto_advance: false` stops Stripe from sending or collecting the invoice. To list each line of your order on the receipt, add one invoice item per line. The items must add up to the captured amount. ## 3. Show the receipt When your customer views their order, fetch the invoice and link to its `hosted_invoice_url`. That Stripe page lets them download the receipt. ::: code-group ```ts [TypeScript] const { hosted_invoice_url } = await stripe.invoices.retrieve(invoiceId); ``` ```python [Python] hosted_invoice_url = stripe.v1.invoices.retrieve(invoice_id).hosted_invoice_url ``` ```php [PHP] $hostedInvoiceUrl = $stripe->invoices->retrieve($invoiceId)->hosted_invoice_url; ``` ```go [Go] inv, err := sc.V1Invoices.Retrieve(ctx, invoiceId, nil) if err != nil { /* handle */ } hostedInvoiceURL := inv.HostedInvoiceURL ``` ```java [Java] String hostedInvoiceUrl = stripe.v1().invoices().retrieve(invoiceId).getHostedInvoiceUrl(); ``` ```ruby [Ruby] hosted_invoice_url = stripe.v1.invoices.retrieve(invoice_id).hosted_invoice_url ``` ```csharp [.NET] var hostedInvoiceUrl = (await stripe.V1.Invoices.GetAsync(invoiceId)).HostedInvoiceUrl; ``` ::: Fetch it each time rather than saving the link: Stripe expires it about 30 days after the invoice is finalized. ::: warning Link the hosted page, not the invoice PDF `invoice_pdf` still shows the amount as due, with a **Pay online** link. ::: ## Good to know * **Add each order once:** capture returns `captured` again if you call it again. Save the invoice ID with your order, and skip if you already have one. * **Refunds go through Maytes:** Stripe refunds nothing for this invoice. Use [Maytes refunds](/refunds). --- --- url: /portal.md --- # Merchant portal Use the Maytes merchant portal to track payments, configure your integration, and manage your account. Your code uses the API; your team uses the portal. Access is set up during [onboarding](/onboarding). The [sandbox](/sandbox-environment) has a separate portal, accounts, and data. ## Settlements Maytes collects customer payments as checkouts are captured. Each business day, a **settlement** batches what you're owed and pays it to your Stripe account on a **2-business-day cycle**. For example, money captured on Monday settles on Wednesday. The portal's settlements dashboard shows: * **Daily settlements** — each batch's total, status, and included checkouts. * **Per-settlement detail** — the orders paid out by each settlement. * **Statements** — a bank-statement-style view of your activity, grouped by checkout, for reconciling against your own records. Your `merchant_order_id`, set when you [create a checkout](/create-checkout), appears with each checkout. Use it to match settlement lines to orders in your system without maintaining a separate UUID mapping. ## Checkouts Browse and search all checkouts by status, amount, and timestamp. The portal shows the same lifecycle as the API's [status endpoint](/capture-and-status#reading-the-checkout-status), so your support team can check whether an order was paid without a separate query tool. ## Refunds [Issue refunds in the portal](/refunds) by finding a captured checkout and choosing refund. You can also issue them through the merchant API. In either case, read the outcome from the checkout status. ## Integration configuration Manage the following integration settings in the portal: | What | Where it's covered | |---|---| | **API credentials** — view your `client_id`, rotate your `client_secret` (shown once, takes effect immediately) | [Authentication](/authentication#get-your-credentials) | | **Stripe onboarding link** — generate a fresh setup link on demand | [Onboarding](/onboarding#_3-complete-your-stripe-account-setup-production-only) | | **Webhook subscription** — endpoint URL and event types | [Webhooks](/webhooks#managing-your-subscription) | | **Webhook signing secret** — view once at creation, rotate with a 2-hour grace window | [Rotating your signing secret](/webhooks#rotating-your-signing-secret) | | **Send a `webhook.test` event** — verify your endpoint end-to-end | [Testing your endpoint](/webhooks#testing-your-endpoint) | | **Webhook delivery log** — every attempt with the HTTP status your endpoint returned | [The delivery log](/webhooks#the-delivery-log) | ## Next * [Onboarding](/onboarding) — get portal access. * [Webhooks](/webhooks) — manage webhook configuration. * [Refunds](/refunds) — issue a refund and track its status. --- --- url: /refunds.md --- # Refunds Return a captured payment through the Maytes merchant portal or merchant API. Refunds settle asynchronously, so keep your order record in sync with the checkout status. ::: info Partial refunds are coming Today a refund returns the **full captured amount**. Support for partial refunds is on its way. ::: Requirements: * Only a **`captured`** checkout can be refunded. For an order that's merely *authorized*, [cancel](/capture-and-status#if-you-can-t-fulfill-the-order) it instead — no money has moved yet. * The customer always gets **100% back** on the same card they used to pay. * Refunds settle **asynchronously** with the payment provider — the checkout passes through a brief `refunding` state before landing on `refunded`. * API requests require a unique **`merchant_refund_ref`**. Reusing the same value safely returns the existing refund. *** ## Issue a refund In the portal, find the captured checkout and choose **Refund**. To refund through the API, call [`POST /api/merchant/v1/checkouts/{checkout_uuid}/refund`](/api-reference#api-1/tag/checkout-merchant/POST/api/merchant/v1/checkouts/{checkoutUuid}/refund) with a unique reference: ```bash curl -sX POST https://api.maytes.co/api/merchant/v1/checkouts/$CHECKOUT_UUID/refund \ -H "authorization: Bearer $ACCESS_TOKEN" \ -H 'content-type: application/json' \ -d '{ "merchant_refund_ref": "refund_order_1001", "reason": "Customer requested a refund" }' ``` The endpoint returns `204` when the refund is accepted. Use `RefundCheckout` in the [backend SDK](/build-your-integration) for the same operation. Then read the checkout until it reaches `refunded`. *** ## What your integration sees After a refund is issued, the checkout moves from `captured` → `refunding` → `refunded`. The [GET checkout](/capture-and-status#reading-the-checkout-status) response includes these refund fields: ```json { "data": { "checkout_uuid": "019fbb9e-ecd1-7059-99f3-f8bf47b63931", "status": "refunded", "amount": 4500, "currency": "AUD", "refunded_amount": 4500, "merchant_processor_fee": 0, "merchant_service_fee": 0, "last_refund_at": "2026-09-14T10:14:33.000Z" } } ``` | Field | What it means | |---|---| | `status` | `refunding` while settling, `refunded` once the payment provider confirms. | | `refunded_amount` | The amount returned to the customer (minor units). `0` while pending; matches the captured amount once settled. | | `last_refund_at` | ISO timestamp of the confirmed refund. `null` while pending. | | `merchant_processor_fee` | Reserved for processor cost billed to you on the refund. **Currently always `0`** — refund fee billing is not yet applied. | | `merchant_service_fee` | Reserved for the Maytes service fee billed to you on the refund. **Currently always `0`.** | ::: tip Customers always get 100% back If refund fee billing is introduced, `merchant_processor_fee` and `merchant_service_fee` will be **billed to you**. They will never be deducted from the customer's refund. ::: ## Keeping your records in sync Treat `refunded` as terminal: the order is unpaid again and the customer has their money. Keep your records in sync in either of these ways: 1. **When reading a checkout** — in support tools and reconciliation jobs, branch on `status` and handle `refunding` and `refunded` with the other [lifecycle states](/capture-and-status#the-checkout-lifecycle). 2. **When issuing the refund** — update your order record when the API accepts the request or your team submits it in the portal. If you need confirmation, poll `GET /checkouts/{uuid}` until `status` is `refunded`. There is no refund webhook event. Because you initiate the refund, use the checkout status to confirm completion. ## What's next * [Capture & checkout status](/capture-and-status) — understand the lifecycle and cancel before capture. * [API reference](/api-reference) — refund request and checkout response schemas. --- --- url: /api-reference.md --- --- --- url: /security.md --- # Security Keep every secret on your server. The requirements below protect payment and customer data. ## Card data never touches you Don't collect card details on behalf of the checkout. Customers enter them in the **Maytes-hosted checkout window**, and our payment provider processes them. Card numbers never pass through your servers, frontend, or the Maytes merchant API, so integrating Maytes doesn't put card data in your PCI scope. There is no API for submitting card details. ## Your three secrets | Secret | Used for | If it leaks | |---|---|---| | `client_secret` (per environment) | Minting OAuth access tokens | An attacker can act as your merchant account: create, capture, cancel, and refund. | | Webhook **signing secret** (per subscription) | Verifying that deliveries came from Maytes | An attacker can forge "authorized" events at your endpoint. | | OAuth **access tokens** | Every merchant API call | Same power as the credentials, until expiry. | Rules for all three: * **Server-side only.** Never put them in browser or mobile code, a repository, or client-accessible configuration. The [Checkout Button](/checkout-button) calls your backend, which calls Maytes. * **Store them in a secret manager** or environment variables injected at deploy time. * **Separate them by environment.** Test and production use independent credentials and webhook secrets, so a test leak doesn't expose production. * **Don't log them.** Redact `authorization` headers and token responses from request logging. ## Verify every webhook delivery Verify the **`X-Maytes-Signature`** header on every webhook using the raw body, constant-time comparison, every `v1` entry, and a timestamp tolerance (we recommend 5 minutes). Reject failures with a 4xx and don't process the payload. These checks prevent forged or replayed requests to your guessable endpoint URL. Use the SDK examples in [Webhooks → Verify the signature](/webhooks#verify-the-signature). ## Rotate secrets without downtime * **Webhook signing secret:** follow the [rotation runbook](/webhooks#rotating-your-signing-secret). The built-in 2-hour dual-signature grace window supports scheduled or staff-departure rotations without dropping deliveries. For a suspected leak, set `revoke_previous=true` to revoke the old secret immediately. * **OAuth client secret:** rotate it from the portal's **API credentials** page. Rotation is immediate, with no grace window, so deploy the new value at once. See [Authentication](/authentication#rotating-your-client-secret). ## Trust server-side state, not the browser Before fulfilling an order, verify server-side state. Browser state, including arrival at your `return_url`, can be forged. * Fulfill on your own **capture success** (a call your server made with its own credentials) — never on the redirect. * Use the capture assertions (`expected_total_amount`, `currency`, `merchant_order_id`) to prevent a mixed-up ID from capturing the wrong order. ## Handle customer data minimally Send the optional `customer_data` block on [create](/create-checkout) only when you have and need the customer's name, email, or mobile. Don't invent placeholder values. Treat this information as personal data under your privacy obligations. Maytes encrypts it at rest and uses it to pre-fill the customer's checkout experience. ## Transport Use HTTPS for your webhook endpoint. All Maytes endpoints are also HTTPS-only. Webhook signatures provide authenticity and integrity; TLS provides confidentiality in transit. ## What's next * [Webhooks](/webhooks) — signature verification and secret rotation in full. * [Authentication](/authentication) — the OAuth token lifecycle. * [Sandbox environment](/sandbox-environment) — including the signature-rejection and duplicate-delivery checks in the go-live list.