# ODUS Documentation > ODUS is a payment orchestration platform. These are the guides for Core API v2, the current version: every Core API path starts with `/v2`. Core API v1 stops being served at 00:00 UTC on 4 November 2026. For exact request and response schemas, read the OpenAPI document linked under Optional. ## Documentation ODUS is a payment orchestration platform that simplifies payment processing through a unified API and a drop-in Checkout SDK. Connect multiple payment service providers, manage subscriptions, and accept payments — all from a single integration. - [ODUS Documentation](https://docs.odus.com/docs.md): ODUS is a payment orchestration platform that simplifies payment processing through a unified API and a drop-in Checkout SDK. Connect multiple payment service providers, manage subscriptions, and accept payments — all from a single integration. ### Guides Learn how to use ODUS in your projects with examples and step-by-step guides. - [Overview](https://docs.odus.com/docs/guides.md): Learn how to use ODUS in your projects with examples and step-by-step guides. #### Upgrading Core API v2 is the current version of the API. v1 stops being served at 00:00 UTC on 4 November 2026. If your integration calls /v1 routes or unprefixed routes such as GET /payments, or uses a Checkout SDK version earlier than 1.12.0, upgrade before then. - [Upgrading](https://docs.odus.com/docs/guides/upgrading.md): Core API v2 is the current version of the API. v1 stops being served at 00:00 UTC on 4 November 2026. If your integration calls /v1 routes or unprefixed routes such as GET /payments, or uses a Checkout SDK version earlier than 1.12.0, upgrade before then. - [Upgrading from v1 to v2](https://docs.odus.com/docs/guides/upgrading/v1-to-v2.md): Core API v2 is the current version of the API. v1 stops being served at 00:00 UTC on 4 November 2026, so 3 November is the last day it answers. Upgrade before then. You can move one flow at a time, and nothing moves to v2 for you. - [Upgrading webhooks to payload version 2](https://docs.odus.com/docs/guides/upgrading/webhook-payload-v2.md): Payload version 2 changes the shape of data in the webhooks you receive. It is optional and separate from the API version. It has no deadline: a subscriber on payload version 1 keeps receiving it after Core API v1 stops being served on 4 November 2026. #### API features Learn about ODUS API and its features with usage examples. - [API Features](https://docs.odus.com/docs/guides/api-features.md): Learn about ODUS API and its features with usage examples. - [Authentication](https://docs.odus.com/docs/guides/api-features/authentication.md): All server-side ODUS API calls must include a secret key to prove identity. This guide shows you how to create a secret key, add it to your requests, and rotate it when needed. - [Data Consistency](https://docs.odus.com/docs/guides/api-features/data-consistency.md): The ODUS API is designed for high throughput and low latency. As a result, some read endpoints can reflect a write with a slight delay. This page explains how consistency works across different endpoint types. - [Error Responses](https://docs.odus.com/docs/guides/api-features/errors.md): Every request the ODUS API rejects answers with the same JSON body, whatever went wrong: - [Metadata](https://docs.odus.com/docs/guides/api-features/metadata.md): Many ODUS API resources support a metadata field that lets you attach custom key-value data to objects. This is useful for storing additional structured information that's relevant to your business logic that is not captured by the standard fields. - [Pagination, filtering, sorting](https://docs.odus.com/docs/guides/api-features/pagination-filtering-sorting.md): Core API v2 paginates by cursor only. page and pageSize are not part of the v2 contract, and v2 also changes filtering semantics and the set of list endpoints. - [Webhooks](https://docs.odus.com/docs/guides/api-features/webhooks.md): ODUS webhooks allow you to receive real-time notifications about events that occur in your ODUS account. This enables you to build integrations that can react to events such as successful payments, recurring payments, subscription updates, and more. #### Payments Learn how to accept payments using ODUS with various payment gateways and methods. - [Payments](https://docs.odus.com/docs/guides/payments.md): Learn how to accept payments using ODUS with various payment gateways and methods. - [Captures](https://docs.odus.com/docs/guides/payments/capture.md): Manual capture separates authorization from the transfer of funds. When you authorize a payment with manual capture, the customer's card is held for the amount but no money is transferred until you explicitly capture it. - [Chargebacks](https://docs.odus.com/docs/guides/payments/chargebacks.md): A chargeback occurs when a cardholder's bank reverses a payment and reclaims the funds on the customer's behalf. ODUS does not automatically detect chargebacks from your payment gateway — you must record them manually when your bank or processor notifies you. - [Error Codes](https://docs.odus.com/docs/guides/payments/error-codes.md): When a payment fails, ODUS reports the reason as a standardized error code. Where the failure came from a payment processor, the processor-specific error is normalized into that same set — so regardless of whether the payment was routed through Stripe, Adyen, PayPal, or any other gateway, the error response uses the same codes. This lets you build a single error-handling path in your integration instead of mapping each processor's errors individually. - [Flow Overview](https://docs.odus.com/docs/guides/payments/flow-overview.md): Learn about how payment flows work in ODUS and the different participants involved in the process. Your application needs to be ready to handle these flows to ensure a smooth payment experience for your customers. - [Making Payments](https://docs.odus.com/docs/guides/payments/making-payments.md): Learn how to create payments for usage with Checkout SDK or direct API calls. - [Working with Saved Payment Methods](https://docs.odus.com/docs/guides/payments/payment-methods.md): A saved payment method is a tokenized card, PayPal account, or Apple Pay wallet stored against a customer. Once saved, it can be reused for future payments and subscription renewals without requiring the customer to enter their details again. - [Payment Statuses](https://docs.odus.com/docs/guides/payments/payment-statuses.md): A payment in ODUS carries two separate status fields: status and displayStatus. Understanding the difference between them — and how they relate to the boolean lifecycle flags — helps you build integrations that correctly handle every payment outcome. - [Refunds and Voids](https://docs.odus.com/docs/guides/payments/refunds.md): Use the POST /v2/payments//reverse endpoint to refund a captured payment or void an uncaptured authorization. The same endpoint handles both operations — ODUS decides which action to perform based on the current state of the payment. #### Checkout ODUS Checkout SDK provides two integration paths for accepting payments in your application. Both are part of the @odus/checkout package and share the same backend APIs, 3DS handling, and payment lifecycle — they differ in how much control you have over the UI. - [Checkout SDK](https://docs.odus.com/docs/guides/checkout.md): ODUS Checkout SDK provides two integration paths for accepting payments in your application. Both are part of the @odus/checkout package and share the same backend APIs, 3DS handling, and payment lifecycle — they differ in how much control you have over the UI. - [3DS and redirect handling](https://docs.odus.com/docs/guides/checkout/3ds-and-redirect-handling.md): This guide applies to both Drop-in Checkout and Elements. The return URL handling and query parameter parsing are identical for both integration paths. - [Choosing Between Drop-in and Elements](https://docs.odus.com/docs/guides/checkout/choosing-integration.md): ODUS Checkout SDK offers two integration paths: Drop-in and Elements. Both use the same backend APIs, handle 3DS authentication, and support the same payment methods — they differ in how much UI control you retain. - [Appearance](https://docs.odus.com/docs/guides/checkout/drop-in/appearance.md): ODUS Checkout SDK lets you customize colors, fonts, button labels, field layout, and the set of supported payment methods through the appearance configuration option. - [Builder](https://docs.odus.com/docs/guides/checkout/drop-in/builder.md): Guides and API reference for ODUS: the Core API, the Checkout SDK, subscriptions, webhooks and order management. - [Configuration](https://docs.odus.com/docs/guides/checkout/drop-in/configuration.md): ODUS Checkout SDK is deeply customizable to fit your application's needs. - [Getting Started](https://docs.odus.com/docs/guides/checkout/drop-in/getting-started.md): Learn how to integrate ODUS Checkout SDK into your application. This guide walks you through the complete setup process with code examples in React. - [SDK Reference](https://docs.odus.com/docs/guides/checkout/drop-in/sdk-reference.md): Complete reference for the OdusCheckout class, its methods, and TypeScript types. - [Builder](https://docs.odus.com/docs/guides/checkout/elements/builder.md): Guides and API reference for ODUS: the Core API, the Checkout SDK, subscriptions, webhooks and order management. - [Configuration](https://docs.odus.com/docs/guides/checkout/elements/configuration.md): ODUS Elements is configured at two levels: - [Examples](https://docs.odus.com/docs/guides/checkout/elements/examples.md): Self-contained React components showing common integration patterns. Each builds on the same three-step flow from Getting Started — the differences are which elements you mount and how you collect customer data. - [Getting Started](https://docs.odus.com/docs/guides/checkout/elements/getting-started.md): This guide walks you through integrating ODUS Elements into a React application — from installation to accepting your first payment. - [SDK Reference](https://docs.odus.com/docs/guides/checkout/elements/sdk-reference.md): Complete API reference for the OdusElements class, BaseElement class, and all TypeScript types. - [Theming](https://docs.odus.com/docs/guides/checkout/elements/theming.md): ODUS Elements uses CSS custom properties for styling, giving you fine-grained control over every element's visual appearance. Each element renders inside its own Shadow DOM and inherits theme variables from CSS custom properties set on its host element. - [Migration to Checkout V1 (iFrame fields)](https://docs.odus.com/docs/guides/checkout/migration-v1.md): The Checkout SDK renders card payment fields inside secure iFrames instead of native browser inputs. This change reduces your PCI DSS scope — sensitive card data (card number, expiry, CVV, and cardholder name) no longer enters your page. #### Subscriptions Learn about ODUS subscriptions and how to manage them using the API. - [Subscriptions](https://docs.odus.com/docs/guides/subscriptions.md): Learn about ODUS subscriptions and how to manage them using the API. - [Cancelling and Resuming](https://docs.odus.com/docs/guides/subscriptions/canceling-and-resuming.md): ODUS gives you three ways to cancel a subscription: immediately, at the end of the current billing period, or at the end of the next one — letting the upcoming renewal charge go through first. Either scheduled option can be reversed by resuming the subscription before it takes effect. - [Creating Subscriptions](https://docs.odus.com/docs/guides/subscriptions/creating-subscriptions.md): A subscription is not created by calling a dedicated endpoint. Instead, ODUS creates a subscription automatically when a payment that contains a recurring price succeeds. This guide explains how to set up that payment so a subscription is activated for your customer. - [Payment Bundling](https://docs.odus.com/docs/guides/subscriptions/payment-bundling.md): When a customer has multiple active subscriptions, ODUS can charge them in a single payment instead of separate charges per subscription. This reduces transaction fees, simplifies billing statements for your customers, and can improve payment success rates by reducing the number of charges. - [Related Subscriptions](https://docs.odus.com/docs/guides/subscriptions/related-subscriptions.md): A single checkout often creates more than one subscription — a paid plan plus a free add-on, or a - [Configure a Payment Retry Policy](https://docs.odus.com/docs/guides/subscriptions/retry-policy.md): When a subscription renewal charge fails, ODUS automatically retries the charge according to the merchant's retry policy (also called a recycle profile). A retry policy defines a schedule of retry attempts, each with configurable delays and gateway options. - [Subscription Lifecycle](https://docs.odus.com/docs/guides/subscriptions/subscription-lifecycle.md): A subscription in ODUS is a recurring billing agreement between a merchant and a customer for a specific product and price. Once created, ODUS automatically charges the customer at each renewal date according to the price's recurring schedule — no further action is required from your backend. - [Updating plans](https://docs.odus.com/docs/guides/subscriptions/updating-plans.md): ODUS allows you to modify active subscriptions by changing their pricing plan at any time. This functionality enables both upgrades (moving to a higher-priced plan) and downgrades (moving to a lower-priced plan) with automatic proration calculations. #### Connecting AI assistants ODUS has a remote MCP server. Add it to Claude, ChatGPT, Cursor, VS Code or another AI assistant, and the assistant can answer questions about your payments, customers, subscriptions, invoices, products and orders by reading them from ODUS, for example "which payments failed yesterday, and why?". - [Connect an AI assistant to ODUS](https://docs.odus.com/docs/guides/ai-connector.md): ODUS has a remote MCP server. Add it to Claude, ChatGPT, Cursor, VS Code or another AI assistant, and the assistant can answer questions about your payments, customers, subscriptions, invoices, products and orders by reading them from ODUS, for example "which payments failed yesterday, and why?". - [Connect ChatGPT to ODUS](https://docs.odus.com/docs/guides/ai-connector/chatgpt.md): Add ODUS to ChatGPT as a custom app in developer mode. - [Connect Claude to ODUS](https://docs.odus.com/docs/guides/ai-connector/claude.md): Add ODUS to Claude as a custom connector. A connector you add on the web is also available in the Claude desktop and mobile apps. - [Connect Claude Code to ODUS](https://docs.odus.com/docs/guides/ai-connector/claude-code.md): Add ODUS to Claude Code as a remote HTTP server. Claude Code can then read your ODUS data while it works in your codebase, for example to check a real payment's shape while you write a webhook handler. - [Connect Cursor to ODUS](https://docs.odus.com/docs/guides/ai-connector/cursor.md): Add ODUS to Cursor as a remote MCP server. Cursor's agent can then read your ODUS data while you work. - [Connect other MCP clients to ODUS](https://docs.odus.com/docs/guides/ai-connector/other-clients.md): Any MCP client that supports remote servers over Streamable HTTP with OAuth can connect to ODUS. Give it the server URL, https://mcp.odus.com/mcp, and nothing else. - [Connect VS Code to ODUS](https://docs.odus.com/docs/guides/ai-connector/vs-code.md): Add ODUS to VS Code as an MCP server. GitHub Copilot can then read your ODUS data in agent mode. #### Customers Customers are reusable records in ODUS that represent the people or organizations you charge. Each customer holds contact information and serves as the central reference point for payments, subscriptions, and saved payment methods. - [Customers](https://docs.odus.com/docs/guides/customers.md): Customers are reusable records in ODUS that represent the people or organizations you charge. Each customer holds contact information and serves as the central reference point for payments, subscriptions, and saved payment methods. - [Restrictions](https://docs.odus.com/docs/guides/customers/customer-restrictions.md): Customer restrictions let you block a specific customer from making payments. You can block new payment attempts made by the customer (CIT — customer-initiated transactions), block recurring subscription charges (MIT — merchant-initiated transactions), or both. - [Managing Customers](https://docs.odus.com/docs/guides/customers/managing-customers.md): A customer record in ODUS represents a person or organization you charge. Each record holds contact information — name, email, phone number, and addresses. Payments, subscriptions, and saved payment methods reference the customer by its id (prefixed cus_). #### Dashboard The ODUS Dashboard is a web interface that allows you to manage your account. Learn how to use the Dashboard to configure your settings. Most dashboard operations can also be performed via the API assuming your Secret API Key has the necessary permissions. - [Dashboard](https://docs.odus.com/docs/guides/dashboard.md): The ODUS Dashboard is a web interface that allows you to manage your account. Learn how to use the Dashboard to configure your settings. Most dashboard operations can also be performed via the API assuming your Secret API Key has the necessary permissions. - [Analytics](https://docs.odus.com/docs/guides/dashboard/analytics.md): The ODUS Dashboard home page shows a real-time summary of your payment activity. It combines a key metrics strip, a customizable grid of revenue and insight widgets, and a monthly rebills calendar. The metrics and widgets are driven by shared date range, granularity, and timezone controls, and you choose which widgets to show, how they are arranged, and how each chart is drawn. - [Gateway Profiles](https://docs.odus.com/docs/guides/dashboard/gateway-profiles.md): A gateway profile is a named set of credentials for one payment gateway connection. For example, your Stripe live account and your Stripe test account are each a separate profile. You can create multiple profiles for the same gateway — for example, one per country or business unit. - [Merchant Configuration](https://docs.odus.com/docs/guides/dashboard/merchant-configuration.md): The Merchant Configuration page lets you set account-wide defaults that apply to all payments. Individual payment requests can override these defaults, but the values you configure here are used when no override is provided. - [Request Logs](https://docs.odus.com/docs/guides/dashboard/request-logs.md): Request Logs are a record of every inbound API request made to ODUS. Use them to debug integration issues, audit API usage, and inspect the exact payloads that were sent and received. - [Manage Team Members](https://docs.odus.com/docs/guides/dashboard/team-management.md): The ODUS Dashboard lets you create user accounts for your team members and assign each one a role that controls what they can do. This guide explains how to add, edit, and remove users in the Dashboard. - [Webhook Subscriber](https://docs.odus.com/docs/guides/dashboard/webhook-subscriber.md): Webhook subscribers represent a destination URL where ODUS will send webhook events for the events you subscribe to. You can create multiple webhook subscribers to send events to different URLs based on your application's needs. #### FAQ Quick answers to the most common questions merchants ask about ODUS. - [Frequently Asked Questions](https://docs.odus.com/docs/guides/faq.md): Quick answers to the most common questions merchants ask about ODUS. #### Integrations Explore the available integration options for connecting external services with ODUS. - [Integrations](https://docs.odus.com/docs/guides/integrations.md): Explore the available integration options for connecting external services with ODUS. - [General](https://docs.odus.com/docs/guides/integrations/gateways.md): Learn how to integrate various payment gateways (e.g., Stripe, PayPal) with ODUS to expand your payment processing options. - [Adyen](https://docs.odus.com/docs/guides/integrations/gateways/adyen.md): Adyen is a global payment company that allows businesses to accept payments in a single system. - [Setting up Apple Pay](https://docs.odus.com/docs/guides/integrations/gateways/adyen/adyen-apple-pay.md): Learn how to configure ODUS to accept Apple Pay payments via Adyen. - [Setting up Webhooks](https://docs.odus.com/docs/guides/integrations/gateways/adyen/adyen-webhooks.md): Learn how to configure ODUS to receive webhook notifications from Adyen. - [Checkout.com](https://docs.odus.com/docs/guides/integrations/gateways/checkout.md): Checkout.com is a cloud-based payment platform that enables businesses to accept payments globally through a unified API. - [Setting up Webhooks](https://docs.odus.com/docs/guides/integrations/gateways/checkout/checkout-webhooks.md): Learn how to configure ODUS to receive webhook notifications from Checkout.com. - [PayPal](https://docs.odus.com/docs/guides/integrations/gateways/paypal.md): PayPal is a widely used digital payments platform that allows businesses to accept PayPal payments, including PayPal Checkout and Pay Later options. A PayPal gateway profile can also process card payments; see Card payments. - [Setting up Webhooks](https://docs.odus.com/docs/guides/integrations/gateways/paypal/paypal-webhooks.md): Learn how to configure ODUS to receive webhook notifications from PayPal. - [Stripe](https://docs.odus.com/docs/guides/integrations/gateways/stripe.md): Stripe is a global payments platform that provides APIs and tools for businesses to accept and manage online payments. - [Setting up Webhooks](https://docs.odus.com/docs/guides/integrations/gateways/stripe/stripe-webhooks.md): Learn how to configure ODUS to receive webhook notifications from Stripe. #### Order Management System Order Management System turns successful payments for physical goods into orders and tracks them through fulfillment — from the moment a customer pays until every item is shipped or cancelled. - [Order Management System](https://docs.odus.com/docs/guides/order-management.md): Order Management System turns successful payments for physical goods into orders and tracks them through fulfillment — from the moment a customer pays until every item is shipped or cancelled. - [Automatic Fulfillment](https://docs.odus.com/docs/guides/order-management/automatic-fulfillment.md): Automatic fulfillment ships orders end to end without manual work: new orders are submitted to your logistics integration automatically, and when the provider ships, the fulfillment is recorded with carrier and tracking details. Fulfillment routes are the rules that decide which orders are submitted to which integration. - [Cancelling Orders](https://docs.odus.com/docs/guides/order-management/cancelling-orders.md): Cancellation removes units from an order that will not ship. It happens in two ways: automatically, when a payment is reversed or its capture fails, and manually, from the order detail page. This guide covers both — and the partial-refund case that connects them. - [Export and Bulk Fulfill](https://docs.odus.com/docs/guides/order-management/export-and-bulk-fulfill.md): Export and Bulk Fulfill form a file-based round-trip with your logistics provider: download your open orders as a file the provider's platform can import, let the provider ship them, then upload the provider's shipment report to mark the orders as fulfilled — hundreds at a time. - [How Orders Are Created](https://docs.odus.com/docs/guides/order-management/how-orders-are-created.md): Orders are created from webhooks which purchases become orders, which of them ship, when multiple payments are bundled into one order, and how customer details stay in sync. - [Importing Orders](https://docs.odus.com/docs/guides/order-management/importing-orders.md): Import creates orders retroactively from payments that already exist. Use it when an order was never created automatically — most commonly because the payment event did not reach Order Management System — or when you enable Order Management System after you have already been taking payments. - [Logistics Integrations](https://docs.odus.com/docs/guides/order-management/integrations.md): A logistics integration connects Order Management System to a third-party logistics (3PL) provider — the company that stores and ships your physical goods. Integrations power the file-based fulfillment round-trip and, where supported, fully automatic fulfillment. - [Inventory](https://docs.odus.com/docs/guides/order-management/inventory.md): The Inventory page shows how many units of each SKU your logistics providers are holding right now, side by side in one table. Use it to spot the SKUs that are running out before orders start waiting for stock. - [Viewing and Managing Orders](https://docs.odus.com/docs/guides/order-management/managing-orders.md): The Orders page is the operational center of Order Management System: find the orders that need action, inspect their details, and work them through fulfillment. - [Order Statuses](https://docs.odus.com/docs/guides/order-management/order-statuses.md): Every order and every order item carries a status derived from its quantities. Understanding how shipped and cancelled units interact helps you read the orders list correctly — especially for orders that were partially refunded or partially shipped. - [PayPal Shipment Tracking](https://docs.odus.com/docs/guides/order-management/paypal-shipment-tracking.md): When you record a fulfillment with a tracking number for an order paid through PayPal, Order Management System automatically submits the shipment details to PayPal. You do not need to copy the tracking number into PayPal separately. - [Setting Up](https://docs.odus.com/docs/guides/order-management/setup.md): Order Management System (OMS) is a first-class addon. Enable it once from your dashboard and every successful payment that contains goods you ship becomes an order automatically — no manual webhook wiring or secrets to copy. #### Products and prices This section covers how products and prices work in ODUS. A product represents something you sell, and a price defines the billing terms for that product — the amount, the currency, and whether the charge is one-time or recurring. Together, products and prices are the foundation for creating subscriptions. - [Products and Prices](https://docs.odus.com/docs/guides/products.md): This section covers how products and prices work in ODUS. A product represents something you sell, and a price defines the billing terms for that product — the amount, the currency, and whether the charge is one-time or recurring. Together, products and prices are the foundation for creating subscriptions. - [Creating products and prices](https://docs.odus.com/docs/guides/products/creating-products-and-prices.md): This guide explains how to create products and prices, and how to manage them after creation. - [What are products and prices?](https://docs.odus.com/docs/guides/products/overview.md): In ODUS, products and prices are the building blocks for billing. Before you can create a subscription or charge a customer, you need at least one product with at least one price attached to it. #### Reports Reports give you aggregated views of payment and customer data across your business. Use them to track revenue performance, compare segments, and export data for offline analysis. - [Reports](https://docs.odus.com/docs/guides/reports.md): Reports give you aggregated views of payment and customer data across your business. Use them to track revenue performance, compare segments, and export data for offline analysis. - [Customer Value Report](https://docs.odus.com/docs/guides/reports/customer-value-report.md): The Customer Value Report breaks revenue down by first charges, upsells, rebills, and lifetime value across any segment of your business — products, prices, currencies, dates, or attribution channels. - [Payment Summary Report](https://docs.odus.com/docs/guides/reports/payment-summary-report.md): The Payment Summary Report aggregates transaction outcomes across your business and splits them into two payment buckets — first-charge (FC) and recurring — alongside refund, chargeback, gross and net revenue metrics. - [Projected Billing Report](https://docs.odus.com/docs/guides/reports/projected-billing-report.md): The Projected Billing Report forecasts upcoming subscription revenue by combining the base amount of every scheduled rebill and recycle (retry) attempt with per-attempt approval rates that you supply. #### Routing The Routing section explains how ODUS decides which payment gateway to use for each card payment. There are two ways to configure it. - [Routing](https://docs.odus.com/docs/guides/routing.md): The Routing section explains how ODUS decides which payment gateway to use for each card payment. There are two ways to configure it. - [How Cascade Routing Works](https://docs.odus.com/docs/guides/routing/cascade-overview.md): A cascade is a named configuration that defines which gateway profiles ODUS uses to process a payment and in what order. When one gateway fails or is no longer available, ODUS automatically routes the payment to the next gateway in the cascade — without any action required from you. - [Configuring Cascade Routing Rules](https://docs.odus.com/docs/guides/routing/configuring-cascades.md): A cascade defines which gateway profiles ODUS uses to process a payment and how traffic is distributed across them. This guide shows how to create and configure a cascade, assign it as the default for card payments, and verify it is active. - [How Routes Work](https://docs.odus.com/docs/guides/routing/how-routes-work.md): A route decides which gateway profile ODUS attempts for a card payment, and where it fails over to after a decline. Unlike a cascade, a route can read the payment: the card's brand, BIN, issuer and funding type, the amount and currency, who initiated it, and your own metadata. So you can send cards from one issuer to one gateway and everything else to another. - [Routing Card Payments with a Route](https://docs.odus.com/docs/guides/routing/routing-card-payments.md): This guide creates a route, points card payments at it, and shows how to read the path a payment took. For how a route decides, see How Routes Work. #### Testing resources This guide covers everything you need to test your ODUS integration in sandbox before going live: test card numbers, example webhook payloads. - [Testing resources](https://docs.odus.com/docs/guides/testing.md): This guide covers everything you need to test your ODUS integration in sandbox before going live: test card numbers, example webhook payloads. #### Using AI tools Every guide here is also available as plain Markdown, so you can give it to an AI assistant or a coding agent without copying from the page. Give the tool the version you integrate with: these files describe Core API v2. - [Using these docs with AI tools](https://docs.odus.com/docs/guides/ai-tools.md): Every guide here is also available as plain Markdown, so you can give it to an AI assistant or a coding agent without copying from the page. Give the tool the version you integrate with: these files describe Core API v2. ## Optional - [Core API v2 reference](https://docs.odus.com/docs/api/core/v2/odus-core-api.md): Every Core API v2 endpoint and webhook event, each linking to its own Markdown page with parameters, request body and responses. - [OMS API reference](https://docs.odus.com/docs/api/oms/odus-oms-api.md): Every Order Management System API endpoint, each linking to its own Markdown page. - [Core API v2 OpenAPI document (production)](https://api.odus.com/openapi.v2.json): Every Core API v2 endpoint, request and response schema, and webhook event body, in OpenAPI 3.1. - [Core API v2 OpenAPI document (sandbox)](https://sandbox-api.odus.com/openapi.v2.json): The same document for the sandbox environment. Use it while you build and test. - [OMS API OpenAPI document](https://sandbox-oms-api.odus.com/openapi.json): The Order Management System API. - [Checkout SDK package](https://www.npmjs.com/package/@odus/checkout) - [Changelog](https://docs.odus.com/changelog): Every merchant-facing change, by release date. --- # Full Documentation Content # ODUS Documentation ODUS is a payment orchestration platform that simplifies payment processing through a unified API and a drop-in Checkout SDK. Connect multiple payment service providers, manage subscriptions, and accept payments — all from a single integration. ### Payments * [Flow overview](https://docs.odus.com/docs/guides/payments/flow-overview.md) * [Making payments](https://docs.odus.com/docs/guides/payments/making-payments.md) [More →](https://docs.odus.com/docs/guides/payments/.md) ### Checkout * [Drop-in Checkout](https://docs.odus.com/docs/guides/checkout/drop-in/getting-started.md) * [3DS and redirect handling](https://docs.odus.com/docs/guides/checkout/3ds-and-redirect-handling.md) [More →](https://docs.odus.com/docs/guides/checkout/.md) ### Subscriptions * [Creating subscriptions](https://docs.odus.com/docs/guides/subscriptions/creating-subscriptions.md) * [Configuring a retry policy](https://docs.odus.com/docs/guides/subscriptions/retry-policy.md) [More →](https://docs.odus.com/docs/guides/subscriptions/.md) --- # Overview Learn how to use ODUS in your projects with examples and step-by-step guides. * [Upgrading](https://docs.odus.com/docs/guides/upgrading/.md) * [Payments](https://docs.odus.com/docs/guides/payments/.md) * [Subscriptions](https://docs.odus.com/docs/guides/subscriptions/.md) * [Products and Prices](https://docs.odus.com/docs/guides/products/.md) * [Customers](https://docs.odus.com/docs/guides/customers/.md) * [Order Management System](https://docs.odus.com/docs/guides/order-management/.md) * [Routing](https://docs.odus.com/docs/guides/routing/.md) * [Checkout](https://docs.odus.com/docs/guides/checkout/.md) * [Integrations](https://docs.odus.com/docs/guides/integrations/.md) * [Dashboard](https://docs.odus.com/docs/guides/dashboard/.md) * [API Features](https://docs.odus.com/docs/guides/api-features/.md) * [Testing](https://docs.odus.com/docs/guides/testing.md) * [Using AI tools](https://docs.odus.com/docs/guides/ai-tools.md) * [Connecting AI assistants](https://docs.odus.com/docs/guides/ai-connector/.md) * [FAQ](https://docs.odus.com/docs/guides/faq.md) --- # Connect an AI assistant to ODUS ODUS has a remote [MCP](https://modelcontextprotocol.io) server. Add it to Claude, ChatGPT, Cursor, VS Code or another AI assistant, and the assistant can answer questions about your payments, customers, subscriptions, invoices, products and orders by reading them from ODUS, for example "which payments failed yesterday, and why?". The server is read-only. An assistant can look data up and search this documentation, but it cannot create, change, refund or delete anything. ## Server URL ``` https://mcp.odus.com/mcp ``` It reads your production data. The server uses the Streamable HTTP transport and OAuth. You sign in with your ODUS Dashboard account. There is no API key to create, and no client ID or secret to enter: your assistant registers itself with ODUS the first time it connects. ## Set up your assistant | Assistant | Guide | | --------------------------------------------- | ------------------------------------------------------------------------------------ | | Claude on the web, desktop and mobile | [Claude](https://docs.odus.com/docs/guides/ai-connector/claude.md) | | Claude Code | [Claude Code](https://docs.odus.com/docs/guides/ai-connector/claude-code.md) | | ChatGPT | [ChatGPT](https://docs.odus.com/docs/guides/ai-connector/chatgpt.md) | | VS Code with GitHub Copilot | [VS Code](https://docs.odus.com/docs/guides/ai-connector/vs-code.md) | | Cursor | [Cursor](https://docs.odus.com/docs/guides/ai-connector/cursor.md) | | Windsurf, Codex, Gemini CLI and other clients | [Other MCP clients](https://docs.odus.com/docs/guides/ai-connector/other-clients.md) | ## Sign in and approve Every assistant sends you to the same ODUS page the first time it connects: 1. Sign in to the Dashboard, if you are not already signed in. The page names the app asking for access. Check that it is the app you are connecting from, and choose **Deny** if it is not. 2. If you belong to more than one organization, choose one under **Organization**. One connection reads one organization. To use another, connect again and choose that one. 3. Choose what the assistant can read: * **All of my permissions** gives it every read permission your role has, and follows your role when it changes. * **Choose permissions** gives it only the permissions you tick. It can't go beyond your role. 4. Choose **Approve**. The browser returns to your assistant, and the server is ready. Most assistants register themselves when they connect, so the page marks them as an unverified app: ODUS cannot confirm who publishes them, and the name shown is the name the app gave itself. That is expected. What matters is that you started the connection yourself, from the assistant the page names. If you choose **Deny**, nothing is connected and your assistant shows that the connection failed. ## What an assistant can read | An assistant can | Needs the permission to read | | -------------------------------------------------------------------- | ---------------------------- | | List the merchant accounts you have access to | No permission needed | | Search the ODUS documentation and read a page | No permission needed | | Read a merchant account's settings | Merchant configuration | | Search across payments, customers and other records by ID or keyword | Search | | Search payments, read a payment and list its transactions | Payments | | Summarise payments over a period | Reports | | List customers and read a customer | Customers | | List subscriptions and read a subscription | Subscriptions | | List invoices and read an invoice | Invoices | | List products and read a product | Products | | List prices and read a price | Prices | | List orders and read an order | Orders | An assistant reads the same data the Dashboard shows you, one merchant account at a time. It never sees a full card number: a payment shows the card brand, the first six and last four digits, the expiry and the cardholder name, as the Dashboard does. API keys, team members and request logs are never available to an assistant, whatever your role. If a connection lacks a permission, the assistant is told it has no access to that data. It doesn't get an empty result. ## Change or remove a connection Each connection is listed in the Dashboard under **Settings > Personal > Connected apps**, with the organization it reads, its permissions and when it was last used. * To change what a connection can read, choose **Edit permissions**. The change applies within a minute. * To disconnect an assistant, choose **Disconnect**. It loses access within a minute. To reconnect, connect from the assistant again and approve it again. A connection also stops working when you leave the organization or your account is deactivated. Signing out does not disconnect an assistant Signing out of the Dashboard or changing your password leaves your connections in place. To stop an assistant reading your data, disconnect it under **Settings > Personal > Connected apps**. If you don't use a connection for 30 days, your assistant asks you to sign in and approve it again. ## Troubleshooting | What you see | What to do | | --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | The assistant says it has no access to some data | The connection lacks that permission, or your role does. Check the connection under **Settings > Personal > Connected apps**, and ask an administrator of your organization if your role lacks it. | | The assistant can't find a payment you can see in the Dashboard | Check that the connection reads the right organization. One connection reads one organization. | | The assistant asks you to sign in again | The connection was disconnected, or unused for 30 days. Sign in and approve again. | | Signing in fails with a redirect or registration error | Update your assistant to its latest version. If it still fails, connect through [`mcp-remote`](https://docs.odus.com/docs/guides/ai-connector/other-clients.md#clients-that-only-run-local-servers), which works with any client that runs local servers. | ## Using the documentation with AI tools The server can also search this documentation, the guides and the Core API v2 and OMS API reference, and read any page of it, so an assistant connected to ODUS can answer integration questions from the current docs. To give a tool the documentation without connecting it, see [Using AI tools](https://docs.odus.com/docs/guides/ai-tools.md). --- # Connect ChatGPT to ODUS Add ODUS to ChatGPT as a custom app in developer mode. ## Before you start * You need a ChatGPT plan that offers developer mode. On a Business or Enterprise workspace, an admin may have to allow it. * Add and use the app on the ChatGPT website. ## Add the app 1. In ChatGPT, open **Settings > Apps**. Under **Advanced settings**, turn on **Developer mode**. 2. Choose **Create**. Enter a name, such as `ODUS`, and the server URL, `https://mcp.odus.com/mcp`. 3. Set authentication to **OAuth** and leave the client ID and secret empty. ChatGPT registers itself with ODUS. 4. Confirm that you trust the app, and choose **Create**. ChatGPT opens the ODUS Dashboard. 5. Sign in and approve the connection, as [Sign in and approve](https://docs.odus.com/docs/guides/ai-connector/.md#sign-in-and-approve) describes. ChatGPT's menu labels change from time to time. If you don't find one of these, OpenAI's help pages on developer mode have the current ones. ## Use it in a chat Choose the ODUS app from the tools menu in the message box, then ask a question, for example "list the orders placed today that are not fulfilled yet". ## Disconnect Remove the app in ChatGPT's settings, and disconnect it in the Dashboard under **Settings > Personal > Connected apps**. --- # Connect Claude to ODUS Add ODUS to Claude as a custom connector. A connector you add on the web is also available in the Claude desktop and mobile apps. ## Before you start * You need a Claude plan that lets you add custom connectors. * On a Team or Enterprise plan, an owner of your Claude organization may have to add the connector first. Each member then connects it with their own ODUS account. ## Add the connector 1. In Claude, open **Settings > Connectors** and choose **Add custom connector**. 2. Enter a name, such as `ODUS`, and paste the server URL, `https://mcp.odus.com/mcp`. 3. Leave the advanced settings empty. Claude registers itself with ODUS, and no client ID or secret is needed. 4. Choose **Add**, then **Connect**. Claude opens the ODUS Dashboard in your browser. 5. Sign in and approve the connection, as [Sign in and approve](https://docs.odus.com/docs/guides/ai-connector/.md#sign-in-and-approve) describes. ## Use it in a chat Turn the connector on for a conversation from the tools menu in the message box, then ask a question. For example: ``` Which payments failed in the last 24 hours, and what were the decline reasons? ``` ``` Show me customer cus_2R0uWz4tu9wxTMBXxj9CJfSE9vV's last three invoices and whether they are paid. ``` Claude asks before it calls a tool for the first time. You can allow a tool once or always. ## Disconnect Remove the connector in Claude under **Settings > Connectors**, and disconnect it in the Dashboard under **Settings > Personal > Connected apps**. Removing it in Claude alone leaves the connection in place in ODUS. --- # Connect Claude Code to ODUS Add ODUS to [Claude Code](https://docs.claude.com/en/docs/claude-code/mcp) as a remote HTTP server. Claude Code can then read your ODUS data while it works in your codebase, for example to check a real payment's shape while you write a webhook handler. ## Add the server Run this in your terminal: ``` claude mcp add --transport http odus https://mcp.odus.com/mcp ``` The server is added for you in the current project. Add `--scope user` to use it in every project, or `--scope project` to share it with your team through the project's `.mcp.json`: ``` { "mcpServers": { "odus": { "type": "http", "url": "https://mcp.odus.com/mcp" } } } ``` The file holds no credential. Each person who uses it signs in with their own ODUS account. ## Sign in 1. Start Claude Code and run `/mcp`. 2. Select the ODUS server and choose **Authenticate**. Your browser opens the ODUS Dashboard. 3. Sign in and approve the connection, as [Sign in and approve](https://docs.odus.com/docs/guides/ai-connector/.md#sign-in-and-approve) describes. The browser shows that you can return to Claude Code. `/mcp` lists the server as connected, with its tools. Claude Code keeps you signed in between sessions. ## Remove the server ``` claude mcp remove odus ``` Then disconnect it in the Dashboard under **Settings > Personal > Connected apps**. --- # Connect Cursor to ODUS Add ODUS to Cursor as a remote MCP server. Cursor's agent can then read your ODUS data while you work. ## Add the server Create `.cursor/mcp.json` in your project for that project only, or `~/.cursor/mcp.json` for every project: ``` { "mcpServers": { "odus": { "url": "https://mcp.odus.com/mcp" } } } ``` The file holds no credential. Each person who uses it signs in with their own ODUS account. ## Sign in 1. Open **Cursor Settings** and find the server in the MCP section. Cursor shows that it needs you to sign in. 2. Choose to connect. Your browser opens the ODUS Dashboard. 3. Sign in and approve the connection, as [Sign in and approve](https://docs.odus.com/docs/guides/ai-connector/.md#sign-in-and-approve) describes. The browser returns to Cursor, and the server shows its tools. If signing in fails Update Cursor to its latest version. If it still fails, connect through `mcp-remote`, as [Clients that only run local servers](https://docs.odus.com/docs/guides/ai-connector/other-clients.md#clients-that-only-run-local-servers) describes, with Cursor's `mcp.json` as the configuration file. ## Use it Ask the agent a question in chat, for example "find the last failed payment for customer cus\_2R0uWz4tu9wxTMBXxj9CJfSE9vV and explain the error". Cursor asks before it runs a tool, unless you allowed it to run tools automatically. ## Disconnect Remove the entry from `mcp.json`, and disconnect it in the Dashboard under **Settings > Personal > Connected apps**. --- # Connect other MCP clients to ODUS Any MCP client that supports remote servers over Streamable HTTP with OAuth can connect to ODUS. Give it the server URL, `https://mcp.odus.com/mcp`, and nothing else. The client finds the ODUS sign-in from the URL, registers itself and opens your browser. Leave any client ID, client secret or API key field empty. Then sign in and approve, as [Sign in and approve](https://docs.odus.com/docs/guides/ai-connector/.md#sign-in-and-approve) describes. ## Codex Add the server, then sign in: ``` codex mcp add odus --url https://mcp.odus.com/mcp codex mcp login odus ``` The first command writes this to `~/.codex/config.toml`, which you can also edit by hand: ``` [mcp_servers.odus] url = "https://mcp.odus.com/mcp" ``` ## Gemini CLI Add the server, then sign in from inside Gemini CLI: ``` gemini mcp add --transport http odus https://mcp.odus.com/mcp ``` ``` /mcp auth odus ``` ## Windsurf and other clients If your client's documentation says it signs in to remote MCP servers with OAuth, give it the server URL. Otherwise, use `mcp-remote`, below. ## Clients that only run local servers Some clients run MCP servers only as local commands. [`mcp-remote`](https://www.npmjs.com/package/mcp-remote) is an open-source command that runs on your machine and connects such a client to a remote server, signing in through your browser. It needs Node.js 18 or later. Add it as a local server in your client's configuration. Most clients use this shape. In Windsurf, the file is `~/.codeium/windsurf/mcp_config.json`: ``` { "mcpServers": { "odus": { "command": "npx", "args": ["-y", "mcp-remote", "https://mcp.odus.com/mcp"] } } } ``` The first time the client starts it, your browser opens the ODUS Dashboard. `mcp-remote` keeps the sign-in in `~/.mcp-auth` on your machine. ## Building your own client If you build an MCP client, it connects the standard way: * `POST` to the server URL without a token answers `401` with a `WWW-Authenticate` header that points at the protected resource metadata (RFC 9728), which names the ODUS authorization server. * The authorization server publishes its metadata (RFC 8414). It supports the authorization code grant with PKCE (`S256`) and refresh tokens, for public clients only: register with `token_endpoint_auth_method` set to `none`. * Register with dynamic client registration (RFC 7591), or use a Client ID Metadata Document URL as your client ID. * Request the `offline_access` scope, and send the server URL as the `resource` parameter (RFC 8707). * A web client's redirect URI must use `https`. A native client uses `http` on `localhost`, `127.0.0.1` or `[::1]`, on any port. Private-use schemes such as `com.example.app:/callback` are not supported. * Refresh tokens rotate on every use. Store the new one each time: reusing an old one ends the connection. --- # Connect VS Code to ODUS Add ODUS to VS Code as an MCP server. GitHub Copilot can then read your ODUS data in agent mode. ## Add the server Create `.vscode/mcp.json` in your project, or add the entry to the file if it exists: ``` { "servers": { "odus": { "type": "http", "url": "https://mcp.odus.com/mcp" } } } ``` The file holds no credential, so you can commit it: each person who uses it signs in with their own ODUS account. To add the server for yourself in every workspace instead, run **MCP: Open User Configuration** from the Command Palette and add the same entry there. **MCP: Add Server** in the Command Palette does the same, step by step: choose **HTTP**, then paste the URL. ## Sign in 1. Start the server: choose **Start** above its entry in `mcp.json`, or run **MCP: List Servers** and start it there. 2. VS Code says the server wants to authenticate to `mcp.odus.com`. Choose **Allow**. Your browser opens the ODUS Dashboard. 3. Sign in and approve the connection, as [Sign in and approve](https://docs.odus.com/docs/guides/ai-connector/.md#sign-in-and-approve) describes. ## Use it Open the Chat view, switch to **Agent** mode, and check that the ODUS tools are ticked in the tools picker. Then ask a question, for example "fetch payment pay\_2mXGTxXIkqv4uSdbm8DJCzvHUIu and write a TypeScript type for its response". Copilot asks before it runs a tool. You can allow a tool for the session, the workspace or always. ## Disconnect Remove the entry from `mcp.json`, and disconnect it in the Dashboard under **Settings > Personal > Connected apps**. --- # Using these docs with AI tools Every guide here is also available as plain Markdown, so you can give it to an AI assistant or a coding agent without copying from the page. Give the tool the version you integrate with: these files describe Core API v2. To let an assistant read your own ODUS data, such as payments and orders, connect it to the ODUS MCP server instead. See [Connect an AI assistant to ODUS](https://docs.odus.com/docs/guides/ai-connector/.md). ## Give a tool the whole documentation | File | Contains | Use it when | | ------------------------------------------------------ | ------------------------------------------------------------------------------ | -------------------------------------------------------------------- | | [`llms.txt`](https://docs.odus.com/llms.txt) | A list of every guide, each with a link to its Markdown and a one-line summary | The tool can fetch links, and should read only the pages it needs | | [`llms-full.txt`](https://docs.odus.com/llms-full.txt) | Every guide in one file | The tool cannot follow links, or you want to load everything at once | Both cover the Core API v2 guides only. The API reference has its own Markdown, described in [Give a tool the API contract](#give-a-tool-the-api-contract). ## Give a tool one page Each guide page and each Core API v2 and OMS API reference page has two buttons next to its breadcrumbs: * **Copy for LLM** copies the page as Markdown, ready to paste into a chat. * **View as Markdown** opens the page as plain text. You can also add `.md` to the end of any of those URLs: ``` https://docs.odus.com/docs/guides/payments/making-payments.md ``` ## Give a tool the API contract Two forms of the API reference are written for tools rather than browsers: * **The reference as Markdown.** [The Core API v2 index](https://docs.odus.com/docs/api/core/v2/odus-core-api.md) lists every endpoint and webhook event. Each entry links to a page with its parameters, request body and responses. The OMS API has [its own index](https://docs.odus.com/docs/api/oms/odus-oms-api.md). * **The OpenAPI document**, below. A tool that generates code or validates requests should read this. Both describe every Core API v2 endpoint, every request and response field, and the body of every webhook event. When a guide and the reference differ on a field name or type, the reference is correct. | Environment | OpenAPI document | | ----------- | ---------------------------------------------- | | Production | `https://api.odus.com/openapi.v2.json` | | Sandbox | `https://sandbox-api.odus.com/openapi.v2.json` | The document is OpenAPI 3.1. You can also generate a typed client and webhook types from it. See [Upgrading from v1 to v2](https://docs.odus.com/docs/guides/upgrading/v1-to-v2.md#if-you-generate-code-from-the-openapi-document) for what to check in your generator. ## Point your coding agent at the docs Most coding assistants can read documentation from a URL, or follow an instructions file in your repository. To make every session start from the right version, add a line like this to your project's `AGENTS.md`, or to the equivalent file your assistant reads: ``` ODUS: read https://docs.odus.com/llms.txt for the guides and https://api.odus.com/openapi.v2.json for the API contract. Use Core API v2: every path starts with /v2. ``` Check the version in what it writes AI models are trained on older copies of the web, so a tool may know only Core API v1 or the API's former name, the Orchestration API. Code it writes from memory can call unprefixed or `/v1` routes, which stop working at 00:00 UTC on 4 November 2026. Give it the files above rather than relying on what it already knows, and check that every Core API path in its output starts with `/v2`. --- # API Features Learn about ODUS API and its features with usage examples. * [Authentication](https://docs.odus.com/docs/guides/api-features/authentication.md) * [Data Consistency](https://docs.odus.com/docs/guides/api-features/data-consistency.md) * [Error Responses](https://docs.odus.com/docs/guides/api-features/errors.md) * [Metadata](https://docs.odus.com/docs/guides/api-features/metadata.md) * [Pagination, Filtering, and Sorting](https://docs.odus.com/docs/guides/api-features/pagination-filtering-sorting.md) * [Webhooks](https://docs.odus.com/docs/guides/api-features/webhooks.md) --- # Authentication All server-side ODUS API calls must include a secret key to prove identity. This guide shows you how to create a secret key, add it to your requests, and rotate it when needed. ## Prerequisites * Access to the ODUS Dashboard with an admin or owner role. * A server environment where you can safely store credentials. ## Secret keys ODUS API keys are secret keys. A secret key starts with `skey_`, is used on your server only, and can be scoped to specific actions. warning Never include a secret key in client-side code, mobile apps, or public repositories. Anyone who obtains your secret key can make API calls on your behalf. The Checkout SDK does not use an API key. Its `apiKey` option is still required, so pass `'pk_retired'`: see [Checkout configuration](https://docs.odus.com/docs/guides/checkout/drop-in/configuration.md#root-options). ## Create a secret key 1. In the ODUS Dashboard, go to **Developers > Access Keys**. 2. Under the **Secret Keys** section, click **Add secret key**. 3. Enter a note to help you identify this key later (for example, `production-server` or `billing-service`). 4. Select the permissions this key needs. Grant only the permissions your integration requires. 5. Click **Add**. 6. Copy the key value immediately. **It is shown only once.** After you leave this page, ODUS will only display a masked version of the key. You can also create a secret key via the API: [`POST /v2/secret-keys`](https://docs.odus.com/docs/api/core/v2/secret-key-controller-create). ## Add the key to your requests Include the secret key as a Bearer token in the `Authorization` header of every server-side API request. curl example ``` curl https://api.odus.com/v2/payments \ -H "Authorization: Bearer skey_your_secret_key_here" \ -H "Content-Type: application/json" ``` fetch example ``` const response = await fetch('https://api.odus.com/v2/payments', { method: 'GET', headers: { Authorization: `Bearer ${process.env.ODUS_SECRET_KEY}`, 'Content-Type': 'application/json', }, }); ``` Store the key in an environment variable, not in source code. Every v2 path starts with `/v2`. A request without the prefix reaches v1, which stops being served on 4 November 2026. ## Rotate a secret key Rotation replaces an old key with a new one. You should rotate a key when: * You suspect the key has been leaked or exposed. * A team member with access to the key leaves your organization. * Your security policy requires periodic rotation. **To rotate a key:** 1. Create a new secret key following the steps above. 2. Update your application to use the new key. 3. Verify the new key works correctly in production. 4. Delete the old key: in the Dashboard, find the key in **Developers > Access Keys** and delete it. You can also use the API: [`POST /v2/secret-keys/{id}/delete`](https://docs.odus.com/docs/api/core/v2/secret-key-controller-delete). important Delete the old key only after your application is fully updated to use the new one. Deleting the old key immediately will break any requests that still use it. A key cannot be switched off and on again: deleting it is the only way to revoke it. `POST /v2/secret-keys/{id}` changes a key's `note` and `permissions` only. --- # Data Consistency The ODUS API is designed for high throughput and low latency. As a result, some read endpoints can reflect a write with a slight delay. This page explains how consistency works across different endpoint types. ## Strongly consistent endpoints All **write operations** (create, update, authorize, capture, reverse, etc.) return the latest state of the resource in the response body. The returned object always reflects the most recent changes. **Retrieve-by-ID** endpoints (e.g. `GET /v2/payments/{id}`) are also strongly consistent and always return the latest state. ``` POST /v2/payments/{id}/authorize → Returns the up-to-date Payment object GET /v2/payments/{id} → Always returns the latest state ``` ## Eventually consistent endpoints **List endpoints** (e.g. `GET /v2/payments`, `GET /v2/customers`, `GET /v2/subscriptions`) are optimized for high-volume read traffic. Under normal operating conditions, data is available on list endpoints within a few hundred milliseconds of a write. In rare cases during high load, propagation may take up to one second. ``` GET /v2/payments → May have a brief delay after a write GET /v2/transactions → May have a brief delay after a write GET /v2/subscriptions → May have a brief delay after a write ``` note This applies to all paginated list endpoints across all resources. ## Response caching Every response carrying merchant data is scoped to the key that requested it, so none of it may be cached. Those responses are returned with: ``` Cache-Control: no-store ``` They carry no `ETag`, and conditional requests (`If-None-Match`, `If-Modified-Since`) are ignored — every request is answered in full, and the API never replies `304 Not Modified`. Configure your HTTP client, proxy, and CDN accordingly: store nothing, and re-request whenever you need current data. The OpenAPI documents, including the v2 document at `/openapi.v2.json`, are the one exception. They are identical for every caller and change only when the API does, so they are returned with `Cache-Control: no-cache` and an `ETag`: cache them, and send `If-None-Match` to revalidate — you'll get a `304 Not Modified` while your copy is current. ## Best practices 1. **Use the write response as the source of truth.** After creating or updating a resource, use the response body — don't immediately poll the list endpoint. 2. **Don't rely on list endpoints for read-after-write flows.** If you need to confirm a payment was authorized, use the response from `POST /v2/payments/{id}/authorize` or retrieve it by ID with `GET /v2/payments/{id}`. 3. **Webhooks deliver the latest state.** If you receive a webhook event and need the full resource, use the retrieve-by-ID endpoint (`GET /v2/payments/{id}`) which is always strongly consistent. ## Summary | Endpoint type | Consistency | Example | | ---------------- | --------------- | ------------------------------------------------------- | | Write operations | Strong | `POST /v2/payments`, `POST /v2/payments/{id}/authorize` | | Retrieve by ID | Strong | `GET /v2/payments/{id}` | | List (paginated) | Eventual (< 1s) | `GET /v2/payments` | --- # Error Responses Every request the ODUS API rejects answers with the same JSON body, whatever went wrong: 400 Bad Request ``` { "statusCode": 400, "requestId": "dfc10de3-36bb-4bcc-b081-316216194033", "message": "amount must be greater than 0", "type": "invalid_request_error", "code": "validation_failed", "errors": ["amount must be greater than 0"] } ``` | Field | Type | Description | | ------------ | ---------- | ---------------------------------------------------------------------------------------------------------------------------- | | `statusCode` | `number` | The HTTP status code, repeated in the body. | | `requestId` | `string` | Identifier for this request. Quote it in a support ticket — it is what we search the logs by. | | `type` | `string` | **Always present.** The kind of failure. This is the field to branch on. See [Error types](#error-types). | | `message` | `string` | Human-readable description, written for a developer reading logs. Not stable — do not match on it. | | `code` | `string` | Optional, finer detail where it adds something `type` does not. See [Error codes](#error-codes). | | `errors` | `string[]` | Present on a validation failure. Every problem found, not just the first. Field-specific entries are prefixed with the path. | ## Branch on `type` `message` is prose. We reword it whenever a clearer phrasing turns up, and it is deliberately not a contract — matching on it will break. `statusCode` is the opposite problem: a `400` covers everything from a missing field to a resource in the wrong state, so it cannot tell you which of those happened. A decline has a status of its own, `402`, but the rest of the coarseness stays. `type` is the stable value in between, and it is on **every** error response including `5xx`. The set is closed and every member is permanent, so you can handle it exhaustively. ``` const response = await fetch(`${BASE_URL}/v2/payments/${id}/authorize`, { method: 'POST', headers: { Authorization: `Bearer ${secretKey}` }, body: JSON.stringify(payload), }); if (!response.ok) { const error = await response.json(); switch (error.type) { case 'invalid_request_error': // Your request is wrong. `error.errors` says how. return showFieldErrors(error.errors); case 'invalid_state_error': // Your request is fine; the resource is not in a state that allows it. return refreshPayment(id); case 'payment_error': // The payment was refused. Nothing is wrong with the request — read the // payment for the reason, with a secret key. return askForAnotherPaymentMethod(); case 'conflict_error': // Retry with backoff only when the resource was held in flight. An // idempotency conflict never succeeds with a different body, and a stale // revision never succeeds until you read the resource again. return error.code === 'resource_locked' ? retryLater() : fail(error); case 'rate_limit_error': return retryWithBackoff(); case 'api_error': // Our side, and `statusCode` is what tells you which of the three you have. if (error.statusCode === 503) return retryWithBackoff(); // transient dependency failure if (error.statusCode === 504) return reconcile(id); // may still have taken effect return reportFailure(error.requestId); // 500 — retrying gets the same answer default: return reportFailure(error.requestId); } } ``` ## Error types Closed set. Every error response carries exactly one. | `type` | Status | Meaning | | ----------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------ | | `invalid_request_error` | `400` | The request is malformed, missing a field, or fails a field-level rule. Fix the request. | | `authentication_error` | `401` | No API key was supplied, or the key is not recognised. | | `permission_error` | `403` | The key is valid but does not carry the permission this operation requires. | | `not_found_error` | `404` | No resource exists with the requested identifier, or it is not visible to this key. | | `invalid_state_error` | `400` | The request is well-formed and permitted, but the resource is not in a state that allows it. | | `conflict_error` | `409` | The request collides with another — a replayed idempotency key, a resource held in flight, or a revision that is out of date. | | `rate_limit_error` | `429` | Too many requests. Back off and retry. See [Rate limits](#rate-limits). | | `payment_error` | `402` | The payment was refused by the issuer, the processor, or risk. | | `api_error` | `5xx` | Something went wrong on our side, or a dependency was briefly unavailable. See [`api_error` is three statuses](#api-error-statuses). | ### `invalid_request_error` versus `invalid_state_error` Both are `400`, and telling them apart is most of the reason `type` exists. `invalid_request_error` means **fix the request**: a field is missing, malformed, or out of range. `errors` names each one. `invalid_state_error` means **the request was fine, but the resource was not**. Capturing a payment that is already captured, cancelling one that has settled, retrying one that was never set up to retry. Nothing about the request will make it succeed — the resource has to change first. Read it before you retry. ### `payment_error` is not your fault either A refused payment is not a bad request. Your payload was correct and the platform worked; the issuer said no. Retrying the identical request will get the identical answer — offer another payment method instead. **A decline answers `402 Payment Required`.** Usually the issuer, the processor or risk refused it, but a payment that never reached an authorization answers `402` too. Treat `402` as "the payment did not go through" and `400` as "the request was not accepted". v1 answers the same decline with `400`, and `type` is `payment_error` in both versions. So an integration moving from v1 that branches on `type` needs no change; one that branches on the status does. See [Upgrading from v1 to v2](https://docs.odus.com/docs/guides/upgrading/v1-to-v2.md). To find out *why* it was refused, read the payment: `outcome.errorCode` on the transaction carries the normalised decline reason, along with `declineType` and `retryStrategy`. See [Error Codes](https://docs.odus.com/docs/guides/payments/error-codes.md) under Payments. Those fields require a secret key — they are not returned to a checkout key, and the error body does not carry them. ## Error codes `code` is the **finer** level, present only where it distinguishes something `type` cannot. Most errors carry no `code` at all. | `code` | Under | Meaning | | ---------------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `validation_failed` | `invalid_request_error` | The request failed schema or field validation; `errors` lists every problem. | | `idempotency_conflict` | `conflict_error` | The `X-Idempotency-Key` was already used with a different body. Retrying will not help. | | `resource_locked` | `conflict_error` | Another in-flight request holds the resource. Retry with backoff. | | `timeout` | `api_error` | The request exceeded the time limit and was abandoned. It **may still have taken effect**. | | `api_version_sunset` | `invalid_request_error` | Answered with `410`: the request used an API version that is no longer served. Send it under a current version. Retrying will not help. | | `stale_revision` | `conflict_error` | The request named a `revision` that is no longer the resource's current one, so nothing was changed. Read the resource again before you retry. Retrying unchanged will not help. | warning **The set of codes is open and grows over time.** New codes appear as we add payment methods and processors. Treat a `code` you do not recognise as if it were absent and fall back to `type`, which is closed and will not surprise you. Never fail on an unfamiliar `code`. `payment_error` carries no `code` today — the decline reason is a secret-key field, and the error body has no equivalent of the key-based gating the payment resource has. The `409`s are the clearest case for why `code` exists: `resource_locked` succeeds if you retry with backoff, while `idempotency_conflict` and `stale_revision` never will. The status cannot tell you which you have. `timeout` is the equivalent on `5xx`. A plain `api_error` means the request did not take effect; `code: "timeout"` means it might have. Reconcile with a `GET` rather than retrying blindly, or retry with the same `X-Idempotency-Key`. ## `api_error` is three statuses, not one `type` alone does not tell you whether to retry a `5xx`. The status does: | Status | Meaning | What to do | | ------ | ------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------- | | `500` | Something failed on our side while serving the request. | Retrying the same request usually gets the same answer. Report it with the `requestId`. | | `503` | A dependency we needed was briefly unavailable. Nothing about your request is wrong, and it never reached the operation. | **Retry with backoff.** This is the one `5xx` that is worth retrying as-is. | | `504` | The request exceeded our processing limit and was abandoned. It **may still have taken effect**. | Reconcile with a `GET`, or retry with the same `X-Idempotency-Key`. | ## Rate limits A request over a rate limit is answered with `429` and `type` `rate_limit_error`. Back off, then retry when the response tells you to. Don't build your integration around a fixed number of requests. An integration that backs off on every `429` keeps working when a limit changes. ### Limits can change We set rate limits, and any limit on concurrent requests, at our discretion. We can change them at any time, on these terms: * **Increases take effect without notice.** Your integration needs no change when a limit goes up. * **Reductions are announced in the [changelog](https://docs.odus.com/changelog) at least 30 days before they take effect.** That gives you time to lower your request rate before the new limit applies. * **Protecting the platform is the exception.** If traffic threatens the API's availability, for example abuse or an incident in progress, we can lower a limit for that traffic immediately and without notice. ## Declines versus errors A payment the issuer declines still creates a transaction, and its full detail lives on the `outcome` object — `errorCode`, `declineType`, `retryStrategy`, `safeErrorMessage`. That is richer than anything the error body carries, and it is the right place to read from when you have a payment to inspect. See [Error Codes](https://docs.odus.com/docs/guides/payments/error-codes.md). The error body's `payment_error` exists so you can tell a decline from a bad request *without* a second call. --- # Metadata Many ODUS API resources support a `metadata` field that lets you attach custom key-value data to objects. This is useful for storing additional structured information that's relevant to your business logic that is not captured by the standard fields. Common use cases include storing order IDs, internal reference numbers, fulfillment tracking codes, or any other contextual information you need to associate with payments, customers, subscriptions, and other resources. ## How metadata works The `metadata` field accepts a JSON object where both keys and values must be strings. When you update a resource with metadata, the new data is merged with any existing metadata rather than replacing it entirely. This means you can update specific keys without affecting others. Initial metadata ``` { "orderId": "12345", "source": "mobile_app" } ``` Update request ``` { "metadata": { "orderId": "67890", "priority": "high" } } ``` Result after merge ``` { "orderId": "67890", "source": "mobile_app", "priority": "high" } ``` ### Clearing metadata fields To remove a specific metadata key, set its value to an empty string in your update request. The key will be deleted from the metadata object: Clearing a single field ``` { "metadata": { "orderId": "" } } ``` Result ``` { "source": "mobile_app", "priority": "high" } ``` To clear all metadata at once, pass `null` as the value for the entire `metadata` field: Clearing all metadata ``` { "metadata": null } ``` ## Validation rules Metadata values must conform to the following constraints: | Rule | Limit | | --------------- | -------------------------------- | | Key-value pairs | Maximum 50 | | Value length | Maximum 500 characters per value | | Value type | Only string values are allowed | Requests that violate these constraints will be rejected with a validation error indicating which rule was broken. ## Working with metadata Metadata can be set during resource creation or updated later using the corresponding update endpoint. Here's an example of creating a payment with metadata: Creating a payment with metadata ``` { "amount": 5000, "currency": "usd", "metadata": { "orderId": "ORD-2024-1234", "campaignId": "SPRING_SALE", "notes": "Priority shipping requested" } } ``` The same approach works for customers, subscriptions, and other resources that support metadata. When retrieving resources, the metadata object is included in the response: Payment response ``` { "id": "pay_abc123", "amount": 5000, "currency": "usd", "status": "succeeded", "metadata": { "orderId": "ORD-2024-1234", "campaignId": "SPRING_SALE", "notes": "Priority shipping requested" } } ``` --- # Pagination, filtering, sorting Core API v2 paginates by **cursor only**. `page` and `pageSize` are not part of the v2 contract, and v2 also changes filtering semantics and the set of list endpoints. Coming from `/v1/...` or the OMS API? [Upgrading from v1 to v2](https://docs.odus.com/docs/guides/upgrading/v1-to-v2.md) lists every difference, and the v1 guides are one switch away in the version picker in the top bar. ## Pagination v2 has one pagination strategy: **cursor pagination**. It uses opaque cursors to page through results and stays fast and consistent on large, frequently changing datasets, where offset pagination can skip or repeat rows as data shifts underneath a page number. Because there is only one strategy, `limit` is no longer an opt-in switch — it is simply the page size. Send no pagination parameters at all and you get the first page with the default size: ``` GET /v2/payments GET /v2/payments?limit=25 ``` `limit` defaults to 10 and is capped at 100. Send `limit=0` to return no items at all, which is how you ask for a count on its own — see [Counting the matches](#counting-the-matches). Every response carries `pagination.mode: "cursor"`. `page` and `pageSize` are rejected, not ignored A v2 request carrying `page` or `pageSize` is answered with `400` and an `errors` entry naming the parameter. They are not silently dropped: `?page=5` would otherwise return the *first* page's rows while looking like the fifth, which is a wrong answer you have no way to detect. ### What you give up, and what to send instead Cursor pagination has no jump-to-page. v2 responses carry no `pageCount`, and there is no way to ask for "page 5". * **To walk a whole result set**, follow `nextCursor` until `hasNextPage` is `false`. * **To show a result count**, send `withTotal=true` — see [Counting the matches](#counting-the-matches). * **To show which rows you are on** ("21–40"), keep a running total of the rows you have already received. Add `data.length` to it each time you step forward and subtract the page you leave behind when you step back. Do not multiply the page number by `limit` — `limit` may change between pages, so the two disagree as soon as it does. ### Counting the matches Send `withTotal=true` and the response's `pagination` gains `totalCount`, the number of items your filters match: ``` GET /v2/payments?limit=20&status[eq]=cancelled&withTotal=true ``` ``` { "pagination": { "mode": "cursor", "pageSize": 20, "hasNextPage": true, "hasPreviousPage": false, "nextCursor": "eyJzIjoiY3JlYXRlZEF0IiwiZCI6ImFzYyIsIm4iOiJmb3J3YXJkIn0", "prevCursor": null, "totalCount": 139, "totalCountIsExact": true }, "data": [ /* Array of items */ ] } ``` Three things to know before you show that number: **Check `totalCountIsExact` first.** Counting stops at 10,000. When more items match, `totalCount` holds 10,000 and `totalCountIsExact` is `false` — the real figure is higher and unknown, so show it as a bound like "over 10,000" rather than as a total. **The count ignores `cursor`.** It describes everything your filters match, not what is left ahead of you, so it does not change as you page. Request it once when the filters change rather than on every page. **Omit it when you do not need it.** A request without `withTotal` does no counting at all. To fetch the count on its own — when the filters change but you already have the rows, or when you want the count and the page to load independently — pair it with `limit=0`: ``` GET /v2/payments?limit=0&status[eq]=cancelled&withTotal=true ``` That returns an empty `data` array and no cursors, because a response with no rows is not a page you can navigate from. Narrow the list before counting If a count feels slow on a large dataset, add a date range alongside your other filters. ### Paging On the first request, send `limit` and omit `cursor`: ``` GET /v2/payments?limit=10 ``` To fetch another page, pass the returned `nextCursor` or `prevCursor` value back in `cursor`, keeping the same `sortBy` and `sortDirection`: ``` GET /v2/payments?limit=10&sortBy=createdAt&sortDirection=desc&cursor=eyJzIjoiY3JlYXRlZEF0IiwiZCI6ImRlc2MiLCJuIjoiZm9yd2FyZCJ9 ``` A cursor encodes the sort field and direction it was issued for, and a request whose `sortBy` or `sortDirection` disagrees is rejected. `limit` is not part of the cursor — you may change it between pages, and it simply changes the size of the next page. Cursors are opaque; do not construct or modify them. An invalid `limit` falls back to the default page size. `0` is valid and means "no items"; a negative number is not. ## Sorting Sort with `sortBy` and `sortDirection`: ``` GET /v2/payments?sortBy=createdAt&sortDirection=desc ``` Each endpoint supports its own `sortBy` fields. The v2 field set is listed in [Supported v2 endpoints](#supported-v2-endpoints). ## Filtering Filter criteria use LHS bracket notation: ``` GET /v2/payments?amount[gt]=100&status[eq]=succeeded ``` The v2 filter operator set is: | Data type | Operators | | -------------------- | ------------------------------------------------- | | Boolean | `eq`, `ne` | | Number | `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `in`, `nin` | | String | `eq`, `ne`, `like`, `ilike`, `in`, `nin` | | String (exact match) | `eq`, `ne`, `in`, `nin` | | Date | `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `in`, `nin` | | JSON metadata values | `eq`, `ne`, `like`, `in`, `nin` | `contains` is not in this table because it depends on the field rather than on the data type: it applies only to a field that holds several values, such as `supportedPaymentMethods`. This is the same in v1 and v2. The API reference lists it on those fields, and sending it on another field the endpoint publishes is answered with `400`, naming the field. A field marked **String (exact match)** in [Supported v2 endpoints](#supported-v2-endpoints) is matched against its whole value: an ID, a reference to another object, a field that holds one of a fixed set of values such as `status`, `currency` or `type`, or card digits such as `card.lastFour`. These accept `eq`, `ne`, `in` and `nin`. `like` or `ilike` on one of them is answered with `400`, naming the field. To match several values, list them with `in`. The processor ID filters, `processorId` on [Transactions](#transactions) and `latestTransaction.processorId` on [Payments](#payments), accept only `eq` and `in`. Send the whole processor ID, in the case the processor returned it. `url` on [Logs](#logs) also accepts only `eq` and `in`: send the whole URL as the log entry shows it, query string included. Any other operator on these fields is answered with `400`, naming the field. ### Filtering by a range of card BINs `paymentMethod.card.bin` on [Payments](#payments) and on the payment summary report ([`GET /v2/reports/payment-summary`](https://docs.odus.com/docs/api/core/v2/reporting-controller-get-payment-summary-report)) also accepts `gte` and `lte`, so you can select every payment whose card BIN is inside a range: ``` GET /v2/payments?paymentMethod.card.bin[gte]=468800&paymentMethod.card.bin[lte]=468899 ``` A BIN is the first 4 to 8 digits of a card number, so the range compares the card numbers each BIN starts, not the text. A bound stands for every card number that starts with it: `gte=4688` and `lte=4688` together select every BIN that begins with `4688`, whether it is 4, 6 or 8 digits long. You can send either bound on its own. A payment matches only when every card number its BIN starts is inside the range. When your bounds have more digits than a payment's BIN, that payment can fall on both sides of a bound, so it is left out. For example, a payment with BIN `468839` is not returned for `gte=46883950` and `lte=46883999`. Each payment returns its BIN in `paymentMethod.card.bin`, so you can see how many digits it has. To include such payments, use bounds with no more digits than the BIN. `eq`, `ne`, `in` and `nin` still match the whole BIN exactly. A bound that is not 1 to 8 digits, a `gte` above the `lte`, and `gt` or `lt` on this field are answered with `400`, naming the field. ### Case sensitivity String filters are case-sensitive in v2 unless you explicitly use `ilike`. For example, `?status[eq]=Succeeded` can match `succeeded` in v1, but in v2 it returns an empty page if the stored value is lowercase. Send an exact-match field such as `status` in the case the API returns it. On a text field, use `ilike` when you want case-insensitive matching, for example `?name[ilike]=acme`. Email addresses are the exception. ODUS stores `customer.email` and the Blacklist `email` in lowercase, and lowercases the value you filter by, so `?email[eq]=Foo@Example.com` and `?email[eq]=foo@example.com` return the same customer. Use `eq` for an email lookup. The `like` operator is also different in v2: it is a prefix match (`value%`), not the v1 substring match (`%value%`). Silent filter changes A case mismatch or a v1-style substring `like` query does not return an error in v2. It simply returns fewer rows, often an empty page. ### Using `in` and `nin` Pass array values by repeating the query parameter: ``` GET /v2/payments?status[in]=succeeded&status[in]=pending ``` Comma notation (for example, `status[in]=succeeded,pending`) is parsed literally and is not supported. ### Using `$and` and `$or` The structural `$and` and `$or` operators remain available in v2. They are query-string structures, so they are not listed as scalar OpenAPI parameters. `$or` is supported only at the root level, and one `$or` group cannot be nested inside another. It *can* be combined with other root filter criteria: those are applied alongside the group with logical `AND`. Conditions inside one `$or` group are also combined with logical `AND`. ``` GET /v2/payments?$or[0][status][eq]=succeeded&$or[0][isCaptured][eq]=true&$or[1][status][eq]=succeeded&$or[1][isReversed][eq]=true&$or[1][isCaptured][eq]=true ``` Use `$and` when multiple `contains` conditions must all be true: ``` GET /v2/gateway-profiles?$and[0][supportedPaymentMethods][contains]=applepay&$and[1][supportedPaymentMethods][contains]=card ``` ### JSON metadata filtering JSON fields, such as `metadata`, support filtering by key with bracket notation. Metadata value filters are case-sensitive. ``` GET /v2/payments?metadata[field1][eq]=value ``` When filtering metadata fields with `ne`, `nin`, `like`, `in`, or comparison operators, entities without that metadata key are excluded due to SQL NULL semantics. To include missing keys, use `$or`: ``` GET /v2/payments?$or[0][metadata][field1][ne]=test&$or[1][metadata][field1][eq]=null ``` ## Supported v2 endpoints The Users, Jobs, and Job Items list endpoints that appear in the v1 guide are not served by v2. ### Accounts Endpoint: [`GET /v2/accounts`](https://docs.odus.com/docs/api/core/v2/account-controller-find-all-paginated) Sortable fields: `id`, `name`, `createdAt` Filterable fields: | Field | Data Type | | ----------- | -------------------- | | `id` | String (exact match) | | `name` | String | | `createdAt` | Date | ### Cascades Endpoint: [`GET /v2/cascades`](https://docs.odus.com/docs/api/core/v2/cascade-controller-find-all-paginated) Sortable fields: `id`, `createdAt`, `updatedAt`, `active`, `name` Filterable fields: | Field | Data Type | | ----------- | -------------------- | | `id` | String (exact match) | | `name` | String | | `active` | Boolean | | `algorithm` | String (exact match) | | `createdAt` | Date | | `updatedAt` | Date | ### Customers Endpoint: [`GET /v2/customers`](https://docs.odus.com/docs/api/core/v2/customer-controller-find-all-paginated) Sortable fields: `id`, `createdAt`, `updatedAt` Filterable fields: | Field | Data Type | | ---------------- | -------------------- | | `id` | String (exact match) | | `createdAt` | Date | | `updatedAt` | Date | | `email` | String | | `name` | String | | `phoneNumber` | String | | `dateOfBirth` | Date | | `restrictCit` | Boolean | | `restrictMit` | Boolean | | `restrictReason` | String | | `metadata` | JSON | ### Gateway Operations Endpoint: [`GET /v2/gateway-operations`](https://docs.odus.com/docs/api/core/v2/gateway-operation-controller-find-all-paginated) Sortable fields: `id`, `createdAt`, `updatedAt`, `type`, `status` Filterable fields: | Field | Data Type | | ------------- | -------------------- | | `id` | String (exact match) | | `createdAt` | Date | | `updatedAt` | Date | | `type` | String (exact match) | | `status` | String (exact match) | | `transaction` | String (exact match) | ### Gateway Profiles Endpoint: [`GET /v2/gateway-profiles`](https://docs.odus.com/docs/api/core/v2/gateway-profile-controller-find-all-paginated) Sortable fields: `id`, `createdAt`, `updatedAt` Filterable fields: | Field | Data Type | | ------------------------- | -------------------- | | `id` | String (exact match) | | `createdAt` | Date | | `updatedAt` | Date | | `title` | String | | `integration` | String (exact match) | | `active` | Boolean | | `supportedPaymentMethods` | String | ### Invoices Endpoint: [`GET /v2/invoices`](https://docs.odus.com/docs/api/core/v2/invoice-controller-find-all-paginated) Sortable fields: `id`, `createdAt`, `updatedAt`, `status` Filterable fields: | Field | Data Type | | -------------- | -------------------- | | `id` | String (exact match) | | `createdAt` | Date | | `updatedAt` | Date | | `status` | String (exact match) | | `currency` | String (exact match) | | `amount` | Number | | `voidedReason` | String | | `periodStart` | Date | | `periodEnd` | Date | | `payment` | String (exact match) | | `number` | String | | `subscription` | String (exact match) | ### Logs Endpoint: [`GET /v2/logs`](https://docs.odus.com/docs/api/core/v2/request-log-controller-find-all-paginated) Sortable fields: `id`, `createdAt`, `updatedAt` Filterable fields: | Field | Data Type | | ------------ | --------------------------- | | `id` | String (exact match) | | `createdAt` | Date | | `updatedAt` | Date | | `requestId` | String (exact match) | | `url` | String (`eq` and `in` only) | | `method` | String (exact match) | | `clientIp` | String (exact match) | | `statusCode` | Number | | `initiator` | String (exact match) | ### Payment Methods Endpoint: [`GET /v2/payment-methods`](https://docs.odus.com/docs/api/core/v2/payment-method-controller-find-all-paginated) Sortable fields: `id`, `createdAt`, `updatedAt`, `isDefault`, `customer` Filterable fields: | Field | Data Type | | --------------- | -------------------- | | `id` | String (exact match) | | `createdAt` | Date | | `updatedAt` | Date | | `isDefault` | Boolean | | `customer` | String (exact match) | | `card.lastFour` | String (exact match) | | `card.brand` | String (exact match) | | `card.expYear` | String (exact match) | | `card.expMonth` | String (exact match) | ### Payments Endpoint: [`GET /v2/payments`](https://docs.odus.com/docs/api/core/v2/payment-controller-find-all-paginated) Sortable fields: `id`, `createdAt`, `updatedAt`, `amount`, `status`, `currency` Filterable fields: | Field | Data Type | | -------------------------------------------- | ---------------------------------------------------------------------------- | | `id` | String (exact match) | | `createdAt` | Date | | `updatedAt` | Date | | `amount` | Number | | `status` | String (exact match) | | `displayStatus` | String (exact match) | | `currency` | String (exact match) | | `amountCaptured` | Number | | `amountReversed` | Number | | `captureMethod` | String (exact match) | | `captureDelay` | Number | | `captureAt` | Date | | `statementDescriptor` | String | | `isCaptured` | Boolean | | `isReversed` | Boolean | | `isFullyReversed` | Boolean | | `isChargebacked` | Boolean | | `isRedirectRequired` | Boolean | | `initiator` | String (exact match) | | `offSessionType` | String (exact match) | | `latestTransaction` | String (exact match) | | `latestTransaction.processorId` | String (`eq` and `in` only) | | `latestTransaction.gatewayProfile.id` | String (exact match) | | `latestTransaction.gatewayProfile.title` | String | | `customer` | String (exact match) | | `customer.email` | String | | `paymentMethod` | String (exact match) | | `paymentMethod.type` | String (exact match) | | `paymentMethod.card.lastFour` | String (exact match) | | `paymentMethod.card.bin` | String (exact match), plus a [BIN range](#filtering-by-a-range-of-card-bins) | | `paymentMethod.card.brand` | String (exact match) | | `paymentMethod.card.expYear` | String (exact match) | | `paymentMethod.card.expMonth` | String (exact match) | | `paymentMethodOptions.card.gatewayProfile` | String (exact match) | | `paymentMethodOptions.card.cascade` | String (exact match) | | `paymentMethodOptions.card.mode` | String (exact match) | | `paymentMethodOptions.paypal.gatewayProfile` | String (exact match) | | `product.name` | String | | `metadata` | JSON | | `product` | String (exact match) | | `latestDeclineType` | String (exact match) | ### Prices Endpoint: [`GET /v2/prices`](https://docs.odus.com/docs/api/core/v2/price-controller-find-all-paginated) Sortable fields: `id`, `createdAt`, `updatedAt`, `amount`, `type` Filterable fields: | Field | Data Type | | -------------- | -------------------- | | `id` | String (exact match) | | `createdAt` | Date | | `updatedAt` | Date | | `amount` | Number | | `currency` | String (exact match) | | `type` | String (exact match) | | `name` | String | | `product` | String (exact match) | | `product.name` | String | | `active` | Boolean | | `metadata` | JSON | ### Products Endpoint: [`GET /v2/products`](https://docs.odus.com/docs/api/core/v2/product-controller-find-all-paginated) Sortable fields: `id`, `createdAt`, `updatedAt`, `name`, `active` Filterable fields: | Field | Data Type | | ----------- | -------------------- | | `id` | String (exact match) | | `createdAt` | Date | | `updatedAt` | Date | | `name` | String | | `active` | Boolean | | `shippable` | Boolean | | `category` | String (exact match) | | `sku` | String | | `metadata` | JSON | ### Secret Keys Endpoint: [`GET /v2/secret-keys`](https://docs.odus.com/docs/api/core/v2/secret-key-controller-find-all-paginated) Sortable fields: `id`, `createdAt`, `updatedAt` Filterable fields: | Field | Data Type | | ----------- | -------------------- | | `id` | String (exact match) | | `createdAt` | Date | | `updatedAt` | Date | | `active` | Boolean | ### Subscriptions Endpoint: [`GET /v2/subscriptions`](https://docs.odus.com/docs/api/core/v2/subscription-controller-find-all-paginated) Sortable fields: `id`, `createdAt`, `updatedAt`, `status`, `currentPeriodStart`, `currentPeriodEnd`, `startDate`, `endDate` Filterable fields: | Field | Data Type | | ------------------------------- | -------------------- | | `id` | String (exact match) | | `createdAt` | Date | | `updatedAt` | Date | | `status` | String (exact match) | | `startDate` | Date | | `endDate` | Date | | `autoBillingEnabled` | Boolean | | `autoBillingDisabledAt` | Date | | `autoBillingDisabledReason` | String (exact match) | | `cancelAtPeriodEnd` | Boolean | | `cancelAtCycle` | Number | | `currentPeriodStart` | Date | | `currentPeriodEnd` | Date | | `currentCycle` | Number | | `metadata` | JSON | | `customer` | String (exact match) | | `customer.email` | String | | `price` | String (exact match) | | `price.product` | String (exact match) | | `setupInvoice.payment` | String (exact match) | | `price.product.name` | String | | `upcomingInvoice.periodStart` | Date | | `invoices.payment.retry.nextAt` | Date | ### Transactions Endpoint: [`GET /v2/transactions`](https://docs.odus.com/docs/api/core/v2/transaction-controller-find-all-paginated) Sortable fields: `id`, `createdAt`, `updatedAt`, `amount`, `status` Filterable fields: | Field | Data Type | | -------------------------- | --------------------------- | | `id` | String (exact match) | | `createdAt` | Date | | `updatedAt` | Date | | `amount` | Number | | `currency` | String (exact match) | | `status` | String (exact match) | | `processorId` | String (`eq` and `in` only) | | `paymentMethod` | String (exact match) | | `payment` | String (exact match) | | `gatewayProfile` | String (exact match) | | `customer` | String (exact match) | | `execution` | String (exact match) | | `paymentMethod.card.bin` | String (exact match) | | `paymentMethod.card.brand` | String (exact match) | ### Webhook Subscribers Endpoint: [`GET /v2/webhook-subscribers`](https://docs.odus.com/docs/api/core/v2/webhook-subscriber-controller-find-all-paginated) Sortable fields: `id`, `createdAt`, `updatedAt` Filterable fields: | Field | Data Type | | ----------- | -------------------- | | `id` | String (exact match) | | `createdAt` | Date | | `updatedAt` | Date | ### Webhook deliveries Endpoint: [`GET /v2/webhook-deliveries`](https://docs.odus.com/docs/api/core/v2/webhook-controller-find-all-deliveries-paginated) Sortable fields: `id`, `createdAt`, `firstDispatchedAt`, `lastDispatchedAt`, `abandonedAt`, `attempts` Filterable fields: | Field | Data Type | | ---------------------- | -------------------- | | `id` | String (exact match) | | `createdAt` | Date | | `firstDispatchedAt` | Date | | `lastDispatchedAt` | Date | | `abandonedAt` | Date | | `success` | Boolean | | `attempts` | Number | | `latestResponseStatus` | Number | | `event.type` | String (exact match) | | `event` | String (exact match) | | `subscriber` | String (exact match) | --- # Webhooks ODUS webhooks allow you to receive real-time notifications about events that occur in your ODUS account. This enables you to build integrations that can react to events such as successful payments, recurring payments, subscription updates, and more. It is important to understand that webhook delivery guarantees are as follows: * Webhooks are delivered **at least once**, meaning that you may receive duplicate events. Use Event IDs to deduplicate events on your end. * Webhooks are **sent in order** of event creation, however, it doesn't mean that they will be **delivered** in the same order, as they may be processed in parallel and there's network latency involved. * Webhooks are sent **asynchronously**, meaning that you may receive events after a delay. The delay is usually less than 5 seconds, but it can be longer in some cases. ## A single action can dispatch multiple events One action on a resource can produce **more than one event**, and you will receive a separate webhook for each event you are subscribed to. The events share no parent — each has its own `eventId`, is delivered independently, and counts as its own delivery for retries. The most common example is an automatically captured payment: it is authorized and captured in a single step, so it emits both a `payment.succeeded` event and a `payment.captured` event at the same time. If you subscribe to both, you will receive two webhooks for that one payment. For a payment that is captured later (delayed or manual capture), the two events arrive separately: `payment.succeeded` at authorization, and `payment.captured` once the funds are collected. important Because these events are delivered independently, **do not assume they will arrive in a fixed order**, and **do not assume one webhook implies another has already been delivered**. Subscribe only to the events you act on, deduplicate by `eventId`, and keep each handler idempotent so that receiving related events in any order — or more than once — has no unintended effect. ## Webhook payload The webhook payload is a JSON object that contains the following fields: * `eventId`: A unique identifier for the event. * `eventType`: The type of the event, e.g. `payment.created`, `payment.updated`, etc. * `profile`: The webhook subscriber profile that the webhook is sent to. * `merchantId`: The identifier of the merchant account the event belongs to. * `timestamp`: The timestamp of the event creation in ISO 8601 format. * `data`: The event data, which is a JSON object that contains the event-specific data. The structure of this object depends on the `eventType` and on the subscriber's [payload version](#payload-versions). The fields around `data` are the same in every payload version, and so is the [signature](#validating-authenticity). Example webhook payload ``` { "eventId": "evt_abc", "eventType": "payment.created", "profile": "whs_xyz", "merchantId": "acct_xyz", "timestamp": "2023-10-01T12:00:00Z", "data": { // Payment object data } } ``` ### Payload versions Webhook payloads have a version, which is separate from the API version. The version decides the shape of `data`. Each subscriber has a `payloadVersion`. You choose it when you create the subscriber, and you cannot change it afterwards. If you don't choose one, the subscriber uses version `1`. | | Version 1 | Version 2 | | --------------------------- | -------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | What `data` contains | The resource as its retrieve endpoint returns it | A fixed set of fields per resource, listed in [Upgrading webhooks to payload version 2](https://docs.odus.com/docs/guides/upgrading/webhook-payload-v2.md#fields-by-resource) | | When `data` is captured | When the webhook is first built, which can be some seconds after the event | At the moment of the event, [with a rare exception](#when-version-2-cannot-capture-the-event) | | Related objects | Expanded into full objects | Only their ID | | Deletion events | Only the ID: `{ "id": "" }` | Normally the resource as it was when it was removed | | `.updated` events | Sent for a narrower set of changes | Sent for every change to a version 2 field | Version 1 continues to be delivered. If support for version 1 is going to end, we will announce it in advance. #### Version 1 `data` follows the response of the resource's retrieve endpoint, including sensitive fields. For example, for a `payment.created` event, `data` contains the payment object described in [`GET /v2/payments/{id}`](https://docs.odus.com/docs/api/core/v2/payment-controller-find-one). Version 1 predates API v2 and shapes `data` the way the v1 retrieve endpoints do. For most resources that is the shape v2 returns too. The exceptions are the fields v2 dropped: a subscription's `invoices`, a customer's `paymentMethods` and `subscriptions`, and a secret key's `active`. A payload may still carry them, so don't build on them. Read those collections from their own list endpoints, as you would on v2. warning On version 1, `data` shows the resource when the webhook is built, not when the event happened. It can include changes made after the event. For example, a `payment.created` webhook can carry a payment that has already been attempted. Use `eventType` to know what happened, and don't treat `data` as the state at that moment. #### Version 2 `data` is a snapshot of the resource, taken at the same moment as the change that caused the event. It never changes afterwards. The webhook defines the shape, not the API response, so it stays the same when the API response changes. Each resource has a fixed set of fields. Related objects are sent as their ID only. [Upgrading webhooks to payload version 2](https://docs.odus.com/docs/guides/upgrading/webhook-payload-v2.md#fields-by-resource) lists the fields for each resource. On version 2, `.updated` is sent whenever an existing resource changes in any version 2 field other than `updatedAt`. It can also be sent for a change to a field that version 2 does not include, and it can arrive alongside the more specific event or on its own. For example, a payment that succeeds produces `payment.succeeded` and can also produce `payment.updated`. This applies to payments, customers, subscriptions, invoices, products and prices. If you don't need these events, don't subscribe to `.updated`. On version 1, `.updated` keeps its narrower set of changes. ##### When version 2 cannot capture the event Rarely, the snapshot cannot be taken at the moment of the event. `data` then has the same version 2 fields, but they are read when the webhook is first built, from the resource as it is at that time. If the resource no longer exists by then, `data` contains only its identifier: `{ "id": "" }`. Make your handler accept a payload that contains only `id`, and use `eventType` to know what happened. Deletion events For events that signal the removal of a resource — such as `price.deleted` — the resource no longer exists when the webhook is delivered. On version 1, `data` contains only its identifier: `{ "id": "" }`. On version 2, `data` normally contains the resource as it was when it was removed, and [rarely](#when-version-2-cannot-capture-the-event) only its identifier. In both versions, use the `eventType` to recognize these events and the `id` to reconcile against your own records. ## Receiving webhooks To start receiving webhooks, you first need to [create a webhook subscriber](https://docs.odus.com/docs/guides/dashboard/webhook-subscriber.md) and save the generated shared secret. This secret is used to sign the webhook payloads, allowing you to verify that the payloads are sent by ODUS. Once you have created a webhook subscriber, saved the shared secret, and exposed a publicly accessible URL to receive webhooks, you will now receive webhooks for all events that you have subscribed to. ### Validating authenticity Each webhooks comes with an HMAC in the `X-Webhook-HMAC` header, which is a SHA256 hash of the payload using the shared secret. To validate the authenticity of the webhook, you should: 1. Retrieve the `X-Webhook-HMAC` header from the incoming request. 2. Compute the HMAC of the request body using the shared secret and the SHA256 algorithm. 3. Compare the computed HMAC with the `X-Webhook-HMAC` header value. Example of validating webhook authenticity ``` import crypto from 'node:crypto'; const sharedSecret = 'your_shared_secret'; const hmac = req.headers['x-webhook-hmac']; const computedHmac = crypto.createHmac('sha256', sharedSecret).update(req.body).digest('hex'); if (hmac !== computedHmac) { // Request did not come from ODUS, reject. return res.status(401).send(); } // Request is valid, process the webhook. ``` ### Responding to webhooks When you receive a webhook, you should respond with a `2xx` status code to acknowledge that you have received the webhook. If you do not respond with a `2xx` status code, the webhook will be retried. If you receive a webhook that you cannot process, you should respond with a `4xx` or `5xx` status code to indicate that there was an error. important Each webhook will be attempted up to 10 times over a period of 3 days with an exponential backoff strategy. If the webhook fails to deliver after 10 attempts, it will be marked as failed and will not be retried again. ### Long-running webhook handlers If your webhook handler takes a long time to process the event, you should respond with a `202 Accepted` status code as soon as you start processing the event. This will acknowledge the receipt of the webhook and prevent it from being retried. Webhooks are considered as failed if they exceed the timeout of 15 seconds. If you need more time to process the event, you can use a background job or a message queue to handle the event asynchronously. ## Moving to a new payload version You cannot change the payload version of an existing subscriber. To move, run a second subscriber on the new version next to the one you have, then deactivate the old one. [Upgrading webhooks to payload version 2](https://docs.odus.com/docs/guides/upgrading/webhook-payload-v2.md#move-a-subscriber-to-version-2) has the steps. ## Best Practices * **Security**: Always validate the incoming requests using the shared secret to ensure that the requests are coming from ODUS. * **Least Privilege**: Only subscribe to the events that you need. This reduces the amount of data you receive and helps you focus on the events that are relevant to your application. * **Idempotency**: Design your webhook handlers to be idempotent, meaning that processing the same event multiple times will not have unintended side effects. Use the `eventId` to deduplicate events if necessary. * **Logging**: Implement logging for your webhook handlers to track incoming events and any errors that occur during processing. This will help you troubleshoot issues. ## Supported Events The [Webhooks section of the API reference](https://docs.odus.com/docs/api/core/v2/webhook-customer-created) lists every event we send, what it means, and the shape of its body on payload version 2. On payload version 2, the `.updated` events are sent for more changes than on version 1. See [Version 2](#version-2). --- # Checkout SDK ODUS Checkout SDK provides two integration paths for accepting payments in your application. Both are part of the `@odus/checkout` package and share the same backend APIs, 3DS handling, and payment lifecycle — they differ in how much control you have over the UI. ## Choose Your Integration ### Drop-in Checkout (`OdusCheckout`) A pre-built, full checkout form rendered into a single container. Ideal when you want to get up and running quickly with minimal frontend code. * Single `mount('#container')` call renders the entire form (card inputs, pay button, error display, alternative payment methods) * Highly customizable through configuration objects (`appearance`, `layout`, `initialValues`) * Handles validation and authorization internally **[Get started with Drop-in →](https://docs.odus.com/docs/guides/checkout/drop-in/getting-started.md)** ### Elements (`OdusElements`) Composable, headless individual input elements that you mount into your own layout. Ideal when you need full control over the checkout experience. * Mount only the elements you need — unmounted elements are automatically skipped during validation and authorization * Full layout control with Shadow DOM encapsulation per element * CSS custom property theming via `applyTheme()` or per-element `setTheme()` * You provide and control the pay button, form layout, and submission flow **[Get started with Elements →](https://docs.odus.com/docs/guides/checkout/elements/getting-started.md)** ## Comparison | Feature | Drop-in | Elements | | --------------------- | ---------------------------------- | -------------------------------------- | | Import | `@odus/checkout` | `@odus/checkout/elements` | | Styles import | `@odus/checkout/styles` | Built-in (none needed) | | UI control | Configuration-based | Full merchant layout | | Layout control | `appearance.layout` config object | Mount elements anywhere in your DOM | | Pay button | Built-in | Merchant-provided | | Validation | Automatic on submit | `validate()` called by merchant | | Authorization trigger | Built-in pay button | `authorize()` called by merchant | | Shadow DOM | Single root for entire form | Per-element Shadow DOM | | Styling approach | `appearance.styles` config object | CSS custom properties (`applyTheme()`) | | Ideal use case | Quick integration, standard layout | Custom checkout experiences | tip Not sure which integration fits best? Read the [Choosing Between Drop-in and Elements](https://docs.odus.com/docs/guides/checkout/choosing-integration.md) guide for a detailed comparison with a decision flowchart. You can also try the interactive [Drop-in Builder](https://docs.odus.com/docs/guides/checkout/drop-in/builder.md) or [Elements Builder](https://docs.odus.com/docs/guides/checkout/elements/builder.md) to experiment directly in your browser. --- # 3DS and redirect handling note This guide applies to both [Drop-in Checkout](https://docs.odus.com/docs/guides/checkout/drop-in/getting-started.md) and [Elements](https://docs.odus.com/docs/guides/checkout/elements/getting-started.md). The return URL handling and query parameter parsing are identical for both integration paths. When processing payments, some payment methods may require additional customer authentication or authorization steps. This is commonly seen with 3D Secure (3DS) for card payments, as well as alternative payment methods like PayPal, which often redirect customers to their own platforms for authorization. It is critical that your application can handle these redirects properly to ensure a smooth payment experience for your customers. info Read more about the flows that may require redirects in [the Payment Flow overview](https://docs.odus.com/docs/guides/payments/flow-overview.md). When a payment requires additional customer authentication, such as 3D Secure for card payments or authorization through an alternative payment method like PayPal, ODUS Checkout will redirect the customer to the appropriate authentication page. After the customer completes the authentication, they will be redirected back to your application using the Return URL you provided during the checkout initialization. ## Return URL Return URL is required for all customer-initiated payments and must be provided when initializing the Checkout SDK. This URL is where the customer will be redirected after completing the authentication process or cancelling the flow. It is important to include any necessary query parameters in the return URL to restore the state of your application after the redirect. ``` const checkout = new OdusCheckout({ ... returnUrl: 'https://my-website.com/checkout?utm_source=newsletter&session_id=abc123', ... }); ``` ## Handling redirect results important Checkout SDK **will not** emit `onPaymentSucceeded` or `onPaymentFailed` events for payments that were authorized through a redirect flow. When the customer is redirected back to your application after completing the payment process, ODUS will append the following query parameters to the return URL: * `success`: A boolean value indicating whether the payment was successful or not. * `payment`: The ID of the payment. * `checkoutKey`: (optional) The checkout key of the payment, only included for failed payments. * `error`: (optional) A boolean value indicating whether there was an error during the payment process. * `message`: (optional) A user-friendly message that provides additional context about the payment status. This data should now be used by your application to determine the outcome of the payment and take appropriate actions. ### Payment succeeded Example Successful Payment Redirect URL ``` https://my-website.com/checkout?utm_source=newsletter&session_id=abc123&success=true&payment=pay_xyz ``` Successful payments will have `success=true` and `payment=pay_xyz` included in the return URL. If you're using the Checkout SDK, you **should not** re-initialize the SDK as the payment has already been completed and doing so will lead to errors. You may continue with your post-payment logic such as displaying a confirmation message, updating order status, or redirecting the customer to a success page. ### Payment failed Example Failed Payment Redirect URL ``` https://my-website.com/checkout?utm_source=newsletter&session_id=abc123&success=false&payment=pay_xyz&checkoutKey=chk_xyz&message=Your%20card%20was%20declined ``` Failed payments will have `success=false`, and both `payment` and `checkoutKey` included in the return URL. Most of the times, a user-friendly `message` will also be included to provide additional context about the failure (e.g., "Your card was declined", "Insufficient funds"). In rare cases, an `error=true` parameter may also be included to indicate that an unexpected error occurred during the payment process. We recommend re-initializing the Checkout SDK with the same configuration used during the previous payment attempt, using the `payment` and `checkoutKey` values from the return URL. To reduce friction, you may also pre-fill any previously entered customer information, such as email address as explained in the [Configuration guide](https://docs.odus.com/docs/guides/checkout/drop-in/configuration.md#initial-values). #### Drop-in Checkout Re-initializing Drop-in Checkout after Failed Payment ``` import { OdusCheckout } from '@odus/checkout'; const searchParams = new URLSearchParams(window.location.search); const paymentId = searchParams.get('payment'); const checkoutKey = searchParams.get('checkoutKey'); const message = searchParams.get('message'); const isError = searchParams.get('error') === 'true'; if (isError) { console.error(`Payment ${paymentId} failed with error: ${message}`); } const customerEmail = window.localStorage.getItem('customerEmail'); const checkout = new OdusCheckout({ apiKey: 'pk_retired', paymentId, checkoutKey, // ... other configuration options initialValues: { email: customerEmail, }, }); checkout.mount('#checkout-container'); // Display the error message from the redirect using built-in Alert styling if (message) { checkout.displayError(message); } ``` #### Elements Re-initializing Elements after Failed Payment ``` import { OdusElements } from '@odus/checkout/elements'; const searchParams = new URLSearchParams(window.location.search); const paymentId = searchParams.get('payment'); const checkoutKey = searchParams.get('checkoutKey'); const message = searchParams.get('message'); const isError = searchParams.get('error') === 'true'; if (isError) { console.error(`Payment ${paymentId} failed with error: ${message}`); } const checkout = new OdusElements({ apiKey: 'pk_retired', environment: 'test', returnUrl: 'https://your-website.com/checkout/return', callbacks: { onPaymentSucceeded: () => { window.location.href = '/success'; }, onPaymentFailed: (error) => console.error('Failed', error), onActionRequired: (url) => { window.location.href = url; }, }, }); // Mount elements into your layout checkout.elements.contact.email.mount('#email-field'); checkout.elements.card.mount('#card-field'); checkout.elements.error.mount('#error-display'); // Display the error message from the redirect if (message) { checkout.elements.error.setError(message); } // Re-associate the payment await checkout.associatePayment(paymentId, checkoutKey); ``` ## Manual action handling In some cases, you may want to handle the redirect flow manually instead of relying on the automatic redirect. One of such cases is to display the authentication page within a modal or iframe on your website, rather than navigating away from your site. ### Drop-in Checkout info To enable manual handling of redirects in Drop-in, set the `manualActionHandling` option to `true` when initializing the Checkout SDK. For more details, refer to the [Configuration guide](https://docs.odus.com/docs/guides/checkout/drop-in/configuration.md#root-options). When `manualActionHandling` is enabled, the Checkout SDK will emit an `onActionRequired` callback when a redirect is needed. This callback provides the `url` to which the customer should be redirected. ``` import { OdusCheckout } from '@odus/checkout'; const checkout = new OdusCheckout({ ... manualActionHandling: true, callbacks: { onActionRequired: (redirectUrl) => { openInModal(redirectUrl); }, ... }, }); ``` ### Elements Elements always fires the `onActionRequired` callback when a redirect is needed — there is no `manualActionHandling` toggle. Handle the redirect URL in whatever way suits your application. ``` import { OdusElements } from '@odus/checkout/elements'; const checkout = new OdusElements({ ... callbacks: { onActionRequired: (redirectUrl) => { openInModal(redirectUrl); }, ... }, }); ``` After completing the payment, the customer will still be redirected to the Return URL you provided during initialization, just as in the automatic flow. You will need to handle the redirect results as described in the [Handling redirect results](#handling-redirect-results) section above. --- # Choosing Between Drop-in and Elements ODUS Checkout SDK offers two integration paths: **Drop-in** and **Elements**. Both use the same backend APIs, handle 3DS authentication, and support the same payment methods — they differ in how much UI control you retain. This page helps you decide which path fits your project. Whichever path you choose, use `@odus/checkout` `1.12.0` or later before 4 November 2026. Version `1.12.0` is the first SDK release that calls only API v2. SDK versions that call API v1 routes stop working when v1 stops being served. ## Comparison | Dimension | Drop-in | Elements | | ------------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ | | **Time to first payment** | Minutes — one `mount()` call renders the full form | Hours — you build the layout, wire up submit, call `validate()` and `authorize()` yourself | | **Customization level** | Configuration-based (`appearance`, `layout`, `initialValues`) | Unlimited — mount elements anywhere in your own markup | | **UI ownership** | ODUS controls the form structure; you configure its look | You own the form; ODUS provides isolated input fields | | **Styling** | `appearance.styles` config object | CSS custom properties via `applyTheme()` or per-element `setTheme()` | | **Pay button** | Built-in, activates automatically after `associatePayment()` | You provide your own button and call `authorize()` | | **Validation** | Automatic on submit | Explicit — call `validate()` from your submit handler | | **Shadow DOM** | Single shadow root for the entire form | Separate shadow root per element | | **Maintenance effort** | Low — SDK upgrades may introduce new UI improvements automatically | Moderate — layout changes are your responsibility | | **Design consistency** | Guaranteed by the SDK | Depends on your implementation | | **Required imports** | `@odus/checkout` + `@odus/checkout/styles` | `@odus/checkout/elements` (styles built-in) | ## Decision Flowchart Use this flowchart to narrow down the right integration for your use case: ## When to Use Drop-in Choose Drop-in when: * You want the shortest path to accepting payments * A standard card form layout is acceptable * You prefer configuration over code for styling * Your team does not have dedicated frontend engineers for the checkout page * You want automatic UI improvements from SDK upgrades without code changes ## When to Use Elements Choose Elements when: * Your checkout page has a custom design that cannot be expressed through configuration alone * You need to place input fields in non-standard positions (e.g., card input inside a multi-step wizard, email field in a header) * You require programmatic control over validation timing and error display * You want to build a checkout experience that matches your product's design system exactly ## Can I Switch Later? Yes. Both paths share the same backend integration (`associatePayment`, `checkoutKey`, payment creation). Switching from Drop-in to Elements (or the reverse) requires only frontend changes — your backend stays the same. tip If you are unsure, start with Drop-in. You can move to Elements later without backend changes if you outgrow the configuration options. --- # Appearance ODUS Checkout SDK lets you customize colors, fonts, button labels, field layout, and the set of supported payment methods through the `appearance` configuration option. tip You can visually tweak all appearance options in the interactive [Builder](https://docs.odus.com/docs/guides/checkout/drop-in/builder.md) and copy the generated configuration into your code. The `appearance` object has three root properties: | Property | Type | Description | | -------------------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | | `additionalPaymentMethods` | [AdditionalPaymentMethods](#additional-payment-methods) | Configures how alternative payment methods, such as PayPal and Apple Pay, are displayed in the checkout | | `styles` | [Styles](#styles) | Customize colors, fonts, and other visual aspects of the checkout form | | `layout` | [Layout](#layout) | Customize text labels and layout options in the checkout form | Example appearance configuration: ``` const checkout = new OdusCheckout({ // ... other configuration options appearance: { additionalPaymentMethods: { paypal: { enabled: true, order: 1, countries: ['US', 'GB', 'DE'], }, applePay: { enabled: true, order: 2, displayName: 'Your Store Name', countries: ['US', 'GB'], }, }, styles: { borderRadius: 5, buttonColor: '#5469d4', buttonFontSize: 16, buttonTextColor: '#ffffff', fontFamily: 'Inter', fontSize: 14, textColor: '#1a1a1a', }, layout: { actionButton: { translationKey: 'getPlan', }, phoneNumber: { enabled: true, label: 'Phone Number', defaultCountry: 'LT', allowedCountries: ['US', 'GB', 'LT'], }, }, }, }); ``` ## Additional Payment Methods important Additional payment methods will only be shown after the payment has been associated via the [associatePayment](https://docs.odus.com/docs/guides/checkout/drop-in/getting-started.md#step-2-associate-a-payment) method. Before that, a placeholder will be shown instead. Each additional payment method can be enabled or disabled individually, and you can also specify the order in which they appear. For Apple Pay, you can customize the name that will be shown in the Apple Pay payment sheet via the `displayName` option. It is important to understand what this configuration does and does not do: * Regional restrictions and browser support still apply. For example, Apple Pay will only be shown to customers using Safari on supported devices. * If the [associated payment](https://docs.odus.com/docs/guides/checkout/drop-in/getting-started.md#step-2-associate-a-payment) does not support a payment method configured in `additionalPaymentMethods`, that method **will not** be shown in the checkout. The same applies if the associated payment supports a payment method not listed in `additionalPaymentMethods` — it also **will not** be shown. The list of currently supported additional payment methods includes: * PayPal * Apple Pay Supported properties for `additionalPaymentMethods`: | Property | Type | Description | | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `paypal.enabled` | boolean | Whether PayPal is enabled as a payment method | | `paypal.order` | number | Order of PayPal button among other additional payment methods | | `paypal.countries` | string\[] | Optional array of ISO 3166-1 alpha-2 country codes where PayPal is available (e.g., `["US", "LT"]`). Matched case-insensitively. Omit it, or pass an empty array, to offer PayPal everywhere | | `applePay.enabled` | boolean | Whether Apple Pay is enabled as a payment method | | `applePay.order` | number | Order of Apple Pay button among other additional payment methods | | `applePay.displayName` | string | The name shown in the Apple Pay payment sheet | | `applePay.countries` | string\[] | Optional array of ISO 3166-1 alpha-2 country codes where Apple Pay is available (e.g., `["US", "LT"]`). Matched case-insensitively. Omit it, or pass an empty array, to offer Apple Pay everywhere | | [`applePay.requiredBillingContactFields`](https://developer.apple.com/documentation/applepayontheweb/applepaypaymentrequest/requiredbillingcontactfields) | ApplePayContactField\[] | Optional array of billing contact fields to request from the customer (see supported values below) | | [`applePay.requiredShippingContactFields`](https://developer.apple.com/documentation/applepayontheweb/applepaypaymentrequest/requiredshippingcontactfields) | ApplePayContactField\[] | Optional array of shipping contact fields to request from the customer (see supported values below) | Supported values for [`ApplePayContactField`](https://developer.apple.com/documentation/applepayontheweb/applepaycontactfield): | Value | Description | | --------------- | ----------------------------------------- | | `email` | Request the customer's email address | | `name` | Request the customer's name | | `phone` | Request the customer's phone number | | `postalAddress` | Request the customer's postal address | | `phoneticName` | Request the phonetic spelling of the name | ## Styles You can customize various visual aspects of the checkout form using the `styles` property. Supported style options include: | Property | Type | Description | | ----------------- | ------ | ------------------------------------------------------------------------ | | `borderRadius` | number | Border radius for input fields | | `buttonColor` | string | Background color of the action button in hexadecimal | | `buttonFontSize` | number | Font size of the action button text | | `buttonTextColor` | string | Text color of the action button in hexadecimal | | `fontFamily` | string | Font family for the checkout form from the list of supported fonts below | | `fontSize` | number | Base font size for the checkout form | | `textColor` | string | Text color for the checkout form in hexadecimal | Supported font families: * `Roboto` * `Open Sans` * `Lato` * `Montserrat` * `Poppins` * `Inter` * `Noto Sans` * `Source Sans Pro` * `Raleway` * `Ubuntu` * `Work Sans` * `Nunito` * `Quicksand` * `Rubik` * `DM Sans` ## Layout You can customize text labels and layout options in the checkout form using the `layout` property. Supported layout options include: | Property | Type | Description | | ----------------------------- | ------- | --------------------------------------------------------------------------------- | | `grouped` | boolean | Controls the visual layout of address fields (default: `true`). See details below | | `actionButton.translationKey` | string | Change the text of the action button from the list below | ### Grouped Address Layout The `grouped` property controls how billing and shipping address fields are visually rendered. It defaults to `true`. **When `grouped` is `true` (default):** * A section title (e.g., "Billing Address") is displayed above the fields * Fields are visually merged into a single block with no gap between them * Only the outer corners of the block are rounded (using the configured `borderRadius`), giving the fields a unified card-like appearance * Fields use placeholders only, without separate labels * Related fields are placed side-by-side where applicable (e.g., first name / last name, city / zip code) **When `grouped` is `false`:** * No section title is displayed * Each field is rendered individually with a `16px` gap between them * Every field has full border radius on all corners * Each field displays both a label above the input and a placeholder inside it * All fields are rendered in full-width rows (no side-by-side layout) * Field order: first name, last name, street, city, country, state, zip code **Configuration:** ``` const checkout = new OdusCheckout({ // ... other configuration options appearance: { layout: { grouped: false, // Render address fields as individual separated inputs }, }, }); ``` Supported translation keys and their values: | Translation Key | Displayed Text | | --------------- | -------------- | | `pay` | PAY | | `submit` | SUBMIT | | `getPlan` | GET MY PLAN | | `donate` | DONATE | | `book` | BOOK NOW | | `order` | ORDER NOW | ### Phone Number Field The phone number field is a compound input that includes a country selector dropdown and a phone number input. The field automatically formats and validates phone numbers based on the selected country. **Visual Layout:** * Country selector displays a flag icon for the selected country * Input field shows the phone number in international format (e.g., "+370 812 34 567") * Both components are styled to appear as a unified field **Behavior:** * Phone numbers are formatted as you type according to the selected country's rules * Validation is country-specific using libphonenumber-js * Output format is E.164 (e.g., "+37081234567") for backend processing * Initial values in E.164 format are automatically parsed and formatted for display **Configuration:** ``` const checkout = new OdusCheckout({ // ... other configuration options appearance: { layout: { phoneNumber: { enabled: true, // Show/hide the phone number field label: 'Contact Number', // Optional custom label defaultCountry: 'LT', // Optional: default country (defaults to 'US') allowedCountries: ['US', 'GB', 'LT', 'DE'], // Optional: limit available countries }, }, }, initialValues: { phoneNumber: '+37061234567', // Pre-fill with E.164 format }, }); ``` | Property | Type | Description | | ------------------------------ | --------- | ---------------------------------------------------------------------------------- | | `phoneNumber.enabled` | boolean | Whether the phone number field is shown | | `phoneNumber.label` | string | Custom label for the phone number field | | `phoneNumber.defaultCountry` | string | Default selected country as ISO 3166-1 alpha-2 code (defaults to 'US') | | `phoneNumber.allowedCountries` | string\[] | Optional array of ISO 3166-1 alpha-2 country codes to show in the country selector | **Note:** If `allowedCountries` is not provided or is an empty array, all countries will be available in the country selector. The country selector displays flag emojis and localized country names, while the input shows the dial code as part of the formatted phone number. ### Address Field Labels Address fields (billing and shipping) support custom labels through the field configuration. If you provide a custom `label` in the field config, it will be used as the placeholder text. Otherwise, the translated placeholder from the built-in locale files is used automatically based on the current locale. For example, the `city` field will display: * "City" in English (`en`) * "Stadt" in German (`de`) * "Ciudad" in Spanish (`es`) * "Ville" in French (`fr`) This applies to all address fields: `street`, `firstName`, `lastName`, `state`, `city`, `zipCode`, and `country`. ### Country Field Dropdown The `country` field is rendered as a dropdown (select input) instead of a text input. You can configure which countries are available in the dropdown using the `options` property: ``` const checkout = new OdusCheckout({ // ... other configuration options appearance: { layout: { billingFields: { country: { enabled: true, options: ['US', 'CA', 'GB', 'DE'], // Only show these countries }, }, shippingFields: { country: { enabled: true, options: ['US', 'CA'], // Different options for shipping }, }, }, }, }); ``` | Property | Type | Description | | -------------------------------- | --------- | ---------------------------------------------------------------------------------------------- | | `billingFields.country.enabled` | boolean | Whether the billing country field is shown | | `billingFields.country.label` | string | Custom label for the billing country field | | `billingFields.country.options` | string\[] | Array of ISO 3166-1 alpha-2 country codes to show in the dropdown (e.g., `["US", "CA", "GB"]`) | | `shippingFields.country.enabled` | boolean | Whether the shipping country field is shown | | `shippingFields.country.label` | string | Custom label for the shipping country field | | `shippingFields.country.options` | string\[] | Array of ISO 3166-1 alpha-2 country codes to show in the dropdown (e.g., `["US", "CA", "GB"]`) | **Note:** If `options` is not provided or is an empty array, all countries will be available in the dropdown. The dropdown displays human-readable country names while submitting the ISO country code as the value. ### State Field Dropdown The `state` field dynamically adapts based on the selected country: * **For the United States, Canada and Australia:** Renders as a **mandatory dropdown** populated with US states, Canadian provinces and territories, or Australian states and territories. Validation requires a selection before the form can be submitted. The submitted value is the subdivision code — `TX`, `ON`, `VIC` — not the full name. * **For all other countries:** Renders as an **optional text input**. The field can be left empty without blocking form submission. The field type updates automatically whenever the customer changes the country dropdown — no additional code is needed. #### Hiding the State Field for Specific Countries Use `hiddenForCountries` to hide the state field entirely when certain countries are selected. When hidden, the field does not appear in the form and validation is skipped — the form can be submitted without a state value. ``` const checkout = new OdusCheckout({ // ... other configuration options appearance: { layout: { billingFields: { country: { enabled: true }, state: { enabled: true, hiddenForCountries: ['LT', 'DE', 'FR'], // Hide state for these countries }, }, }, }, }); ``` ### Street Address Max Length Use `maxLength` to limit the number of characters a customer can type into the street address field. When the limit is reached, the input stops accepting characters and a localized validation message is displayed below the field. ``` const checkout = new OdusCheckout({ // ... other configuration options appearance: { layout: { billingFields: { street: { enabled: true, maxLength: 100, // Limit street address to 100 characters }, }, shippingFields: { street: { enabled: true, maxLength: 200, // Different limit for shipping }, }, }, }, }); ``` | Property | Type | Description | | --------------------------------- | ------ | --------------------------------------------------------------- | | `billingFields.street.maxLength` | number | Maximum characters allowed in the billing street address field | | `shippingFields.street.maxLength` | number | Maximum characters allowed in the shipping street address field | **Note:** If `maxLength` is not provided, the field accepts unlimited characters. The validation message is localized automatically based on the current locale. --- # Builder Loading builder… --- # Configuration ODUS Checkout SDK is deeply customizable to fit your application's needs. ## Root Options | Option | Type | Required | Description | | ---------------------- | ------------------------------------------------------------------------------ | -------- | -------------------------------------------------------------------------------------------------------- | | `apiKey` | string | ✅ | Required, but not used since version 0.14.0 of the SDK. Pass `'pk_retired'`. | | `environment` | string | ✅ | API environment to use - either 'test' or 'live' | | `returnUrl` | string | ✅ | The URL where the customer will be redirected after 3DS authentication or other redirect flows | | `locale` | [Locale](#localization) | | Language code for checkout localization | | `appearance` | [Appearance](https://docs.odus.com/docs/guides/checkout/drop-in/appearance.md) | | Customize the checkout UI appearance | | `initialValues` | [InitialValues](#initial-values) | | Pre-fill form fields with initial values | | `disableErrorMessages` | boolean | | Set to `true` to disable built-in error messages (defaults to `false`) | | `manualActionHandling` | boolean | | Set to `true` to manually handle 3DS redirects via the `onActionRequired` callback (defaults to `false`) | | `callbacks` | [Callbacks](#callbacks-and-event-handling) | | Event callback functions | ## Callbacks and Event Handling It is important to handle various events during the payment process. You can provide callback functions in the `callbacks` configuration option to respond to these events. Updating Callbacks After Initialization In reactive frameworks (React, Vue, Svelte), callbacks defined at mount time capture state via closure and can become stale when dependencies change. Use `setCallbacks()` to update callbacks without remounting: ``` // React example useEffect(() => { checkoutRef.current?.setCallbacks({ onPaymentSucceeded: () => trackPurchase(priceId, quantity), onPaymentFailed: (result) => logError(priceId, result), }); }, [priceId, quantity]); ``` Only the callbacks you pass are replaced — omitted callbacks remain unchanged. ### onPaymentSucceeded Called when a payment is successfully authorized. This doesn't include redirect-based flows such as PayPal or 3DS authentication. ``` onPaymentSucceeded: (result) => { // result is the payment status: 'authorized' console.log('Payment successful!', result); // Update UI, redirect to thank you page, etc. }; ``` ### onPaymentFailed Called when a payment fails. This doesn't include redirect-based flows such as PayPal or 3DS authentication. ``` onPaymentFailed: (result) => { // result is the payment status: 'failed' console.log('Payment failed!', result); // Show error message, allow retry, etc. }; ``` ### onActionRequired Called when a payment requires additional action (such as 3DS authentication) and `manualActionHandling` is set to `true`. This allows you to customize how the redirect is handled, such as opening it in a modal or a new window. ``` onActionRequired: (redirectUrl) => { // redirectUrl is the URL where the customer needs to be redirected for 3DS authentication console.log('Action required!', redirectUrl); // Handle the redirect manually, e.g., open in a modal, new window, or custom redirect window.location.href = redirectUrl; }; ``` ### onLoadingStateChange Called when the loading state of the checkout changes. You can use this to show or hide additional loading indicators in your application. ``` onLoadingStateChange: (isLoading) => { if (isLoading) { console.log('Checkout is loading...'); // Show loading spinner } else { console.log('Checkout finished loading.'); // Hide loading spinner } }; ``` ## Initial Values In some cases, you may want to pre-fill certain fields in the checkout form, for example if you capture customer's email address earlier in the flow. You can do this by providing the `initialValues` option in the configuration: ``` const checkout = new OdusCheckout({ // other configuration options initialValues: { email: 'user@example.com', name: 'John Doe', phoneNumber: '+1234567890', billingAddress: { firstName: 'John', lastName: 'Doe', street: '123 Main St', city: 'New York', state: 'NY', zipCode: '10001', country: 'US', }, shippingAddress: { firstName: 'John', lastName: 'Doe', street: '456 Oak Ave', city: 'Los Angeles', state: 'CA', zipCode: '90001', country: 'US', }, }, }); ``` Supported values for `initialValues` include: | Field | Type | Description | | --------------- | --------------------- | ---------------------------------------------------------- | | email | string | Pre-fill the customer's email | | name | string | Pre-fill the cardholder name | | phoneNumber | string | Pre-fill the customer's phone number | | billingAddress | Partial\ | Pre-fill billing address fields (all fields are optional) | | shippingAddress | Partial\ | Pre-fill shipping address fields (all fields are optional) | The `AddressData` type includes the following fields: `firstName`, `lastName`, `street`, `city`, `state`, `zipCode`, and `country`. You can provide any subset of these fields. note Card-related fields (`cardNumber`, `cardExpiry`, `cardCvv`) cannot be pre-filled for PCI DSS compliance reasons. ## Localization By default, ODUS Checkout will automatically detect and set the language based on the customer's browser settings. However, you can override this behavior by specifying a `locale` in the configuration options: ``` const checkout = new OdusCheckout({ // ... other configuration options locale: 'fr', // Set language to French }); ``` ODUS Checkout supports 11 languages with the following locale codes: | Locale Code | Language | | ----------- | ---------- | | `en` | English | | `fr` | French | | `de` | German | | `es` | Spanish | | `it` | Italian | | `pl` | Polish | | `pt` | Portuguese | | `tr` | Turkish | | `cs` | Czech | | `nl` | Dutch | | `sk` | Slovak | --- # Getting Started Learn how to integrate ODUS Checkout SDK into your application. This guide walks you through the complete setup process with code examples in React. ## Prerequisites Before you begin, make sure you have: 1. A backend endpoint that creates payments via the ODUS API — see [Making Payments](https://docs.odus.com/docs/guides/payments/making-payments.md) for details. ## How It Works ODUS Checkout uses a two-step initialization process: 1. **Mount** — Renders the checkout form UI (card inputs, pay button) into your page 2. **Associate** — Links the checkout to a payment created on your backend, enabling the pay button and loading available payment methods The checkout is interactive immediately after mounting, but the pay button remains disabled until a payment is associated. You should both mount and associate as early as possible in your checkout flow to minimize wait times for customers. ## Step 1: Install and Mount the Checkout First, install the Checkout SDK via NPM: ``` npm install @odus/checkout@latest ``` If your app already depends on the SDK, make sure it resolves to version `1.12.0` or later before 4 November 2026. Version `1.12.0` is the first SDK release that calls only API v2. SDK versions that call API v1 routes stop working when v1 stops being served. Then we'll create a React component that mounts the checkout form. At this stage, we'll set up the basic structure — the checkout UI will render, but the pay button won't be functional yet. src/components/CheckoutForm.tsx ``` import { useEffect, useRef } from 'react'; import { OdusCheckout } from '@odus/checkout'; import '@odus/checkout/styles'; // Required for proper styling export function CheckoutForm() { const checkoutRef = useRef(null); useEffect(() => { const checkout = new OdusCheckout({ environment: 'test', // Use 'live' for production apiKey: 'pk_retired', returnUrl: 'https://your-website.com/checkout/return', }); checkout.mount('#checkout-container'); checkoutRef.current = checkout; return () => { checkoutRef.current?.unmount(); }; }, []); return
; } ``` After this step, you'll see the card input form rendered on the page. The pay button appears but is disabled — that's expected. Let's fix that in the next step. important Don't forget to import the styles with `import '@odus/checkout/styles'`. Without this, the checkout form won't render correctly. ## Step 2: Associate a Payment For the checkout to accept payments, you need to link it to a payment created on your backend. Your backend should call the ODUS API to create a payment and return two values: `paymentId` and `checkoutKey`. info Learn how to create payments on your backend in the [Making Payments](https://docs.odus.com/docs/guides/payments/making-payments.md) guide. Here's the complete component with payment association and proper error handling: src/components/CheckoutForm.tsx ``` import { useEffect, useRef, useState } from 'react'; import { OdusCheckout } from '@odus/checkout'; import '@odus/checkout/styles'; export function CheckoutForm() { const checkoutRef = useRef(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { const checkout = new OdusCheckout({ environment: 'test', apiKey: 'pk_retired', returnUrl: 'https://your-website.com/checkout/return', callbacks: { onPaymentSucceeded: () => { window.location.href = '/checkout/success'; }, onPaymentFailed: (errorMessage) => { setError(`Payment failed: ${errorMessage}`); }, }, }); checkout.mount('#checkout-container'); checkoutRef.current = checkout; // Fetch payment details from your backend and associate async function setupPayment() { try { const response = await fetch('/api/create-payment', { method: 'POST', headers: { 'Content-Type': 'application/json' }, }); if (!response.ok) { throw new Error('Failed to create payment'); } const { paymentId, checkoutKey } = await response.json(); await checkout.associatePayment(paymentId, checkoutKey); } catch (err) { setError('Failed to initialize payment. Please refresh the page.'); } finally { setIsLoading(false); } } setupPayment(); return () => { checkoutRef.current?.unmount(); }; }, []); return (
{isLoading &&
Loading checkout...
} {error &&
{error}
}
); } ``` Once `associatePayment()` completes successfully: * The pay button becomes active * Alternative payment methods (PayPal, Apple Pay) appear if configured * Customers can complete their purchase tip Call `associatePayment()` as early as possible in your checkout flow. This minimizes the time customers spend waiting for the checkout to become ready. ## Updating the Payment In many checkout flows, the customer can change their selected plan or pricing option after the checkout form is already rendered. When this happens, you need to create a new payment on your backend and re-associate it with the existing checkout instance. Call `associatePayment()` again on the **same instance** — do not unmount and recreate the checkout. The SDK preserves all user-entered form data (email, card details, address fields) and only refreshes the payment method buttons. src/components/CheckoutForm.tsx ``` import { useEffect, useRef, useState, useCallback } from 'react'; import { OdusCheckout } from '@odus/checkout'; import '@odus/checkout/styles'; const PRICES = [ { id: 'basic', label: 'Basic — $9/mo', amount: 900 }, { id: 'pro', label: 'Pro — $29/mo', amount: 2900 }, ]; export function CheckoutForm() { const checkoutRef = useRef(null); const [selectedPrice, setSelectedPrice] = useState(PRICES[0]); useEffect(() => { const checkout = new OdusCheckout({ environment: 'test', apiKey: 'pk_retired', returnUrl: 'https://your-website.com/checkout/return', callbacks: { onPaymentSucceeded: () => { window.location.href = '/checkout/success'; }, }, }); checkout.mount('#checkout-container'); checkoutRef.current = checkout; return () => { checkoutRef.current?.unmount(); }; }, []); // Re-associate whenever the selected price changes useEffect(() => { const checkout = checkoutRef.current; if (!checkout) return; async function updatePayment() { const response = await fetch('/api/create-payment', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ amount: selectedPrice.amount }), }); const { paymentId, checkoutKey } = await response.json(); await checkout!.associatePayment(paymentId, checkoutKey); } updatePayment(); }, [selectedPrice]); return (
{PRICES.map((price) => ( ))}
); } ``` warning Do not destroy and recreate the checkout instance when the price changes. Unmounting and remounting creates a new form, which **wipes all fields** the customer has already filled in. ## Next Steps Now that you have the basics set up, there are only a few steps left to get your checkout flow production-ready: * [3DS and Redirect Handling](https://docs.odus.com/docs/guides/checkout/3ds-and-redirect-handling.md) — Handle redirect-based payment flows like 3DS authentication and PayPal * [Configuring Checkout](https://docs.odus.com/docs/guides/checkout/drop-in/configuration.md) — Customize the appearance and behavior of the checkout form * [Builder](https://docs.odus.com/docs/guides/checkout/drop-in/builder.md) — Visually experiment with styles, layout, and payment methods in an interactive playground --- # SDK Reference Complete reference for the `OdusCheckout` class, its methods, and TypeScript types. ## OdusCheckout Class ``` class OdusCheckout { constructor(config: CheckoutConfig); mount(containerId: string): this; unmount(): void; associatePayment(paymentId: string, checkoutKey: string): Promise; setCallbacks(callbacks: Partial): void; displayError(message: string): void; clearError(): void; } ``` ### Methods * **`constructor(config: CheckoutConfig)`** — Creates a new checkout instance with the provided configuration. * **`mount(containerId: string)`** — Mounts the checkout form to the specified container element. Returns `this` for chaining. * **`unmount()`** — Unmounts and cleans up the checkout form. * **`associatePayment(paymentId: string, checkoutKey: string)`** — Associates a payment with the checkout session. This enables payment method options and checkout details to be displayed. Can be called multiple times on the same instance — all user-entered form data (email, card details, address fields) is preserved, and only the payment method buttons (card, PayPal, Apple Pay) are refreshed. This is the recommended way to handle price or plan changes without losing form state. * **`setCallbacks(callbacks: Partial)`** — Updates one or more callback functions after initialization. Only the provided callbacks are replaced; omitted callbacks remain unchanged. This eliminates stale closure issues in reactive frameworks (React, Vue, Svelte) where callbacks capture state at mount time. * **`displayError(message: string)`** — Displays a custom error message using the checkout's built-in Alert UI. This method bypasses the `disableErrorMessages` configuration flag. Throws an error if called before `mount()`. * **`clearError()`** — Removes the currently displayed error message. Throws an error if called before `mount()`. ## Programmatic Error Display The Checkout SDK provides methods to programmatically display and clear error messages using the built-in Alert UI. This is particularly useful for showing errors that occur outside the normal payment flow, such as after a failed 3DS redirect. ### Displaying Custom Errors Use the `displayError()` method to show an error message: ``` const checkout = new OdusCheckout({ // ... configuration }); checkout.mount('#checkout-container'); // Display a custom error message checkout.displayError('Payment failed after 3DS authentication. Please try again.'); ``` **Important notes:** * The `displayError()` method always displays the message, even when `disableErrorMessages: true` is set in the configuration, since it represents an explicit intent to show a message * The method must be called after the checkout is mounted, otherwise it will throw an error * The error uses the SDK's built-in Alert styling with red color scheme and slide-in animation ### Clearing Error Messages Use the `clearError()` method to remove the currently displayed error: ``` // Clear the error message checkout.clearError(); ``` **Common use cases:** * Displaying errors after returning from 3DS authentication * Showing validation errors from your backend * Communicating payment failures that occur outside the checkout flow * Clearing errors before re-initializing or retrying a payment ## CheckoutConfig Type ``` type CheckoutConfig = { apiKey: string; returnUrl: string; environment: 'test' | 'live'; callbacks?: { onPaymentSucceeded?: (result: string) => void; onPaymentFailed?: (result: string) => void; onActionRequired?: (redirectUrl: string) => void; onLoadingStateChange?: (isLoading: boolean) => void; }; locale?: Locale; appearance?: Appearance; initialValues?: { email?: string; name?: string; phoneNumber?: string; billingAddress?: Partial; shippingAddress?: Partial; }; disableErrorMessages?: boolean; manualActionHandling?: boolean; }; // Where Locale is a type representing supported languages type Locale = 'en' | 'de' | 'es' | 'fr' | 'pl' | 'pt' | 'it' | 'tr' | 'cs' | 'nl' | 'sk'; // Contact fields that can be requested from Apple Pay type ApplePayContactField = 'email' | 'name' | 'phone' | 'postalAddress' | 'phoneticName'; // Appearance configuration type type Appearance = { additionalPaymentMethods?: { paypal?: { enabled: boolean; order: number; countries?: string[]; }; applePay?: { enabled: boolean; order: number; displayName?: string; countries?: string[]; requiredBillingContactFields?: ApplePayContactField[]; requiredShippingContactFields?: ApplePayContactField[]; }; }; styles?: { borderRadius: number; buttonColor: string; buttonFontSize: number; buttonTextColor: string; fontFamily: string; fontSize: number; textColor: string; }; layout?: { actionButton?: { label: string; translationKey: string; }; phoneNumber?: { enabled: boolean; label?: string; defaultCountry?: string; allowedCountries?: string[]; }; billingFields?: { street?: { enabled: boolean; label?: string }; city?: { enabled: boolean; label?: string }; state?: { enabled: boolean; label?: string; hiddenForCountries?: string[] }; zipCode?: { enabled: boolean; label?: string }; firstName?: { enabled: boolean; label?: string }; lastName?: { enabled: boolean; label?: string }; country?: { enabled: boolean; label?: string; options?: string[] }; }; shippingFields?: { street?: { enabled: boolean; label?: string }; firstName?: { enabled: boolean; label?: string }; lastName?: { enabled: boolean; label?: string }; city?: { enabled: boolean; label?: string }; state?: { enabled: boolean; label?: string; hiddenForCountries?: string[] }; zipCode?: { enabled: boolean; label?: string }; country?: { enabled: boolean; label?: string; options?: string[] }; }; }; }; ``` ## CheckoutInstance Type ``` // This represents the checkout instance returned when you create a new OdusCheckout interface CheckoutInstance { mount(containerId: string): this; unmount(): void; associatePayment(paymentId: string, checkoutKey: string): Promise; setCallbacks(callbacks: Partial): void; displayError(message: string): void; clearError(): void; } ``` --- # Builder Loading builder… --- # Configuration ODUS Elements is configured at two levels: * **Global** — via the `ElementsConfig` object passed to the `OdusElements` constructor. * **Per-element** — via `MountOptions` passed to `update()` after mounting individual elements. ## ElementsConfig | Option | Type | Required | Description | | -------------------------- | ------------------------------------------------------- | -------- | ---------------------------------------------------------------------------- | | `apiKey` | string | ✅ | Required, but not used since version 0.14.0 of the SDK. Pass `'pk_retired'`. | | `environment` | `'test'` \| `'live'` | ✅ | API environment | | `returnUrl` | string | ✅ | URL to redirect to after 3DS authentication or other redirect flows | | `locale` | [Locale](#localization) | | Language for all elements (auto-detected by default) | | `callbacks` | [Callbacks](#callbacks) | | Payment lifecycle event handlers | | `additionalPaymentMethods` | [AdditionalPaymentMethods](#additional-payment-methods) | | Express payment method config (e.g., Apple Pay) | ``` const checkout = new OdusElements({ apiKey: 'pk_retired', environment: 'test', returnUrl: 'https://your-website.com/checkout/return', locale: 'en', callbacks: { onPaymentSucceeded: (result) => console.log('Succeeded', result), onPaymentFailed: (error) => console.error('Failed', error), onActionRequired: (url) => { window.location.href = url; }, }, }); ``` ## Callbacks Handle payment lifecycle events by passing callback functions in the `callbacks` option. Updating Callbacks After Initialization In reactive frameworks (React, Vue, Svelte), callbacks defined at mount time capture state via closure and can become stale when dependencies change. Use `setCallbacks()` to update callbacks without remounting: ``` // React example useEffect(() => { checkoutRef.current?.setCallbacks({ onPaymentSucceeded: () => trackPurchase(priceId, quantity), onPaymentFailed: (result) => logError(priceId, result), }); }, [priceId, quantity]); ``` Only the callbacks you pass are replaced — omitted callbacks remain unchanged. ### onPaymentSucceeded Fires when a payment is successfully authorized. Does **not** fire for redirect-based flows (PayPal, 3DS). ``` onPaymentSucceeded: (result) => { // result is the payment status: 'authorized' window.location.href = '/checkout/success'; }; ``` ### onPaymentFailed Fires when a payment fails. Does **not** fire for redirect-based flows. ``` onPaymentFailed: (result) => { // result is a user-friendly error message console.error('Payment failed:', result); }; ``` ### onActionRequired Fires when a payment requires a redirect (e.g., 3DS authentication, PayPal authorization). ``` onActionRequired: (redirectUrl) => { window.location.href = redirectUrl; }; ``` info Unlike Drop-in Checkout, Elements has no `manualActionHandling` toggle — `onActionRequired` always fires when a redirect is needed. Handle the URL however you like (navigate, open in a modal, etc.). ### onLoadingStateChange Fires when the loading state changes while a payment is being authorized — `true` when the request starts and `false` when it settles. Use it to show or hide your own loading indicator. ``` onLoadingStateChange: (isLoading) => { if (isLoading) { // Show loading spinner, disable the pay button } else { // Hide loading spinner, re-enable the pay button } }; ``` ## Localization Elements auto-detects the customer's browser language by default. Override this globally in `ElementsConfig`: ``` const checkout = new OdusElements({ // ...other options locale: 'fr', }); ``` ### Supported Locales | Code | Language | | ---- | ---------- | | `en` | English | | `fr` | French | | `de` | German | | `es` | Spanish | | `it` | Italian | | `pl` | Polish | | `pt` | Portuguese | | `tr` | Turkish | | `cs` | Czech | | `nl` | Dutch | | `sk` | Slovak | ## Additional Payment Methods Configure express payment methods through the `additionalPaymentMethods` option. ### Apple Pay ``` const checkout = new OdusElements({ // ...other options additionalPaymentMethods: { applePay: { displayName: 'My Store', countries: ['US', 'GB'], requiredBillingContactFields: ['postalAddress'], requiredShippingContactFields: ['email', 'name'], }, }, }); ``` | Option | Type | Description | | ------------------------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `displayName` | string | Name shown in the Apple Pay payment sheet | | `countries` | string\[] | ISO 3166-1 alpha-2 country codes where Apple Pay is available, matched case-insensitively. Omit it, or pass an empty array, to offer Apple Pay everywhere. When the list is non-empty the button stays hidden until the shopper's country is known | | `requiredBillingContactFields` | ApplePayContactField\[] | Billing fields to request ([Apple docs](https://developer.apple.com/documentation/applepayontheweb/applepaypaymentrequest/requiredbillingcontactfields)) | | `requiredShippingContactFields` | ApplePayContactField\[] | Shipping fields to request ([Apple docs](https://developer.apple.com/documentation/applepayontheweb/applepaypaymentrequest/requiredshippingcontactfields)) | **ApplePayContactField values:** `email`, `name`, `phone`, `postalAddress`, `phoneticName` note PayPal requires no additional configuration — simply mount the `express.payPal` element and it renders automatically after `associatePayment()` is called, provided the payment supports PayPal. ## Per-Element Options (MountOptions) Customize individual elements via `update()` after mounting: ``` checkout.elements.contact.email.mount('#email-container'); checkout.elements.contact.email.update({ locale: 'fr', disabled: true, initialValue: 'user@example.com', }); ``` ``` // Limit street address to 100 characters checkout.elements.address.street.mount('#street-container'); checkout.elements.address.street.update({ maxLength: 100 }); ``` ``` // Override the label and placeholder to match your existing checkout copy checkout.elements.address.street.update({ label: 'Street Address', placeholder: 'e.g. 123 Main St', }); ``` | Option | Type | Description | | -------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------- | | `locale` | string | Override the global locale for this element | | `disabled` | boolean | Render the element in a disabled state | | `initialValue` | varies | Pre-fill the element (type depends on the element) | | `maxLength` | number | Maximum character limit for text inputs (e.g., `address.street`) | | `label` | string | Override the field label. Applies to text elements (address, cardholder, email, phone). Defaults to the localized copy | | `placeholder` | string | Override the field placeholder. Applies to text elements (address, cardholder, email, phone). Defaults to the localized copy | | `callbacks` | object | Per-element event callbacks | ### Per-Element Events Register event callbacks using `on()`: ``` checkout.elements.contact.email.on('change', (data) => console.log('changed', data)).on('blur', () => console.log('blurred')); ``` | Event | Description | | -------- | --------------------------------------------- | | `change` | Element value changed | | `blur` | Element lost focus | | `focus` | Element gained focus | | `error` | Element encountered a validation error | | `ready` | Element finished rendering and is interactive | --- # Examples Self-contained React components showing common integration patterns. Each builds on the same three-step flow from [Getting Started](https://docs.odus.com/docs/guides/checkout/elements/getting-started.md) — the differences are **which elements you mount** and **how you collect customer data**. ## Minimal Checkout **Use when:** You only need card details and an email — the simplest possible form. Mounts email, cardholder name, card, and error elements. Unmounted elements (phone, address) are automatically skipped by `validate()` and `authorize()`. src/components/MinimalCheckout.tsx ``` import { useEffect, useRef, useState } from 'react'; import { OdusElements } from '@odus/checkout/elements'; export function MinimalCheckout() { const checkoutRef = useRef(null); const emailRef = useRef(null); const cardholderRef = useRef(null); const cardRef = useRef(null); const errorRef = useRef(null); const [isFormValid, setIsFormValid] = useState(false); useEffect(() => { const checkout = new OdusElements({ apiKey: 'pk_retired', environment: 'test', returnUrl: 'https://your-website.com/checkout/return', callbacks: { onPaymentSucceeded: () => { window.location.href = '/success'; }, onPaymentFailed: (error) => console.error('Failed', error), onActionRequired: (url) => { window.location.href = url; }, }, }); checkout.elements.contact.email.mount(emailRef.current!); checkout.elements.cardholderName.mount(cardholderRef.current!); checkout.elements.card.mount(cardRef.current!); checkout.elements.error.mount(errorRef.current!); checkout.onChange(() => setIsFormValid(checkout.isValid)); checkoutRef.current = checkout; fetch('/api/create-payment', { method: 'POST' }) .then((res) => res.json()) .then(({ paymentId, checkoutKey }) => checkout.associatePayment(paymentId, checkoutKey)); return () => checkoutRef.current?.destroy(); }, []); async function handlePay() { const checkout = checkoutRef.current!; const { valid, errors } = checkout.validate(); if (!valid) { console.error('Validation errors:', errors); return; } await checkout.authorize(); } return (
); } ``` *** ## Full Checkout **Use when:** You need complete customer information — email, phone, full address, and card. All elements are mounted, giving you full layout control. Demonstrates `update()` to set initial values after mounting. src/components/FullCheckout.tsx ``` import { useEffect, useRef, useState } from 'react'; import { OdusElements } from '@odus/checkout/elements'; export function FullCheckout() { const checkoutRef = useRef(null); const emailRef = useRef(null); const phoneRef = useRef(null); const firstNameRef = useRef(null); const lastNameRef = useRef(null); const streetRef = useRef(null); const cityRef = useRef(null); const zipCodeRef = useRef(null); const countryRef = useRef(null); const stateRef = useRef(null); const cardRef = useRef(null); const errorRef = useRef(null); const [isFormValid, setIsFormValid] = useState(false); useEffect(() => { const checkout = new OdusElements({ apiKey: 'pk_retired', environment: 'test', returnUrl: 'https://your-website.com/checkout/return', callbacks: { onPaymentSucceeded: () => { window.location.href = '/success'; }, onPaymentFailed: (error) => console.error('Failed', error), onActionRequired: (url) => { window.location.href = url; }, }, }); checkout.elements.contact.email.mount(emailRef.current!); checkout.elements.contact.email.update({ initialValue: 'user@example.com' }); checkout.elements.contact.phone.mount(phoneRef.current!); checkout.elements.address.firstName.mount(firstNameRef.current!); checkout.elements.address.lastName.mount(lastNameRef.current!); checkout.elements.address.street.mount(streetRef.current!); checkout.elements.address.city.mount(cityRef.current!); checkout.elements.address.zipCode.mount(zipCodeRef.current!); checkout.elements.address.country.mount(countryRef.current!); checkout.elements.address.country.update({ initialValue: 'US' }); checkout.elements.address.state.mount(stateRef.current!); checkout.elements.card.mount(cardRef.current!); checkout.elements.error.mount(errorRef.current!); checkout.onChange(() => setIsFormValid(checkout.isValid)); checkoutRef.current = checkout; fetch('/api/create-payment', { method: 'POST' }) .then((res) => res.json()) .then(({ paymentId, checkoutKey }) => checkout.associatePayment(paymentId, checkoutKey)); return () => checkoutRef.current?.destroy(); }, []); async function handlePay() { const checkout = checkoutRef.current!; const { valid, errors } = checkout.validate(); if (!valid) { console.error('Validation errors:', errors); return; } await checkout.authorize(); } return (
{ e.preventDefault(); handlePay(); }} >
); } ``` *** ## Custom Payload **Use when:** You want to collect some fields (email, name) with your own native inputs while using Elements only for card data. Mounts only card and error elements. Merchant-collected values are passed to `authorize()` via the `customerData` option — these are deep-merged on top of element-derived values. src/components/CustomPayloadCheckout.tsx ``` import { useEffect, useRef, useState } from 'react'; import { OdusElements } from '@odus/checkout/elements'; export function CustomPayloadCheckout() { const checkoutRef = useRef(null); const cardRef = useRef(null); const errorRef = useRef(null); const [email, setEmail] = useState(''); const [name, setName] = useState(''); const [isCardValid, setIsCardValid] = useState(false); useEffect(() => { const checkout = new OdusElements({ apiKey: 'pk_retired', environment: 'test', returnUrl: 'https://your-website.com/checkout/return', callbacks: { onPaymentSucceeded: () => { window.location.href = '/success'; }, onPaymentFailed: (error) => console.error('Failed', error), onActionRequired: (url) => { window.location.href = url; }, }, }); checkout.elements.card.mount(cardRef.current!); checkout.elements.error.mount(errorRef.current!); checkout.onChange(() => setIsCardValid(checkout.isValid)); checkoutRef.current = checkout; fetch('/api/create-payment', { method: 'POST' }) .then((res) => res.json()) .then(({ paymentId, checkoutKey }) => checkout.associatePayment(paymentId, checkoutKey)); return () => checkoutRef.current?.destroy(); }, []); async function handlePay() { const checkout = checkoutRef.current!; if (!email.trim() || !name.trim()) { alert('Please fill in all fields'); return; } const { valid, errors } = checkout.validate(); if (!valid) { console.error('Validation errors:', errors); return; } await checkout.authorize({ customerData: { email: email.trim(), name: name.trim(), }, }); } const isFormValid = isCardValid && email.trim() !== '' && name.trim() !== ''; return (
setEmail(e.target.value)} /> setName(e.target.value)} />
); } ``` *** ## PayPal Only **Use when:** You want to offer PayPal as the sole payment method — no card inputs needed. The PayPal button handles authorization and redirect automatically; you don't call `authorize()` manually. src/components/PayPalCheckout.tsx ``` import { useEffect, useRef } from 'react'; import { OdusElements } from '@odus/checkout/elements'; export function PayPalCheckout() { const checkoutRef = useRef(null); const paypalRef = useRef(null); const errorRef = useRef(null); useEffect(() => { const checkout = new OdusElements({ apiKey: 'pk_retired', environment: 'test', returnUrl: 'https://your-website.com/checkout/return', callbacks: { onPaymentSucceeded: () => { window.location.href = '/success'; }, onPaymentFailed: (error) => console.error('Failed', error), onActionRequired: (url) => { window.location.href = url; }, }, }); checkout.elements.express.payPal.mount(paypalRef.current!); checkout.elements.error.mount(errorRef.current!); checkoutRef.current = checkout; fetch('/api/create-payment', { method: 'POST' }) .then((res) => res.json()) .then(({ paymentId, checkoutKey }) => checkout.associatePayment(paymentId, checkoutKey)); return () => checkoutRef.current?.destroy(); }, []); return (
); } ``` *** ## Apple Pay Only **Use when:** You want to offer Apple Pay as the sole payment method (Safari / supported devices only). Apple Pay configuration (display name, countries, required contact fields) is set in `ElementsConfig.additionalPaymentMethods`. The button only renders on supported devices after `associatePayment()` succeeds. src/components/ApplePayCheckout.tsx ``` import { useEffect, useRef } from 'react'; import { OdusElements } from '@odus/checkout/elements'; export function ApplePayCheckout() { const checkoutRef = useRef(null); const applePayRef = useRef(null); const errorRef = useRef(null); useEffect(() => { const checkout = new OdusElements({ apiKey: 'pk_retired', environment: 'test', returnUrl: 'https://your-website.com/checkout/return', additionalPaymentMethods: { applePay: { displayName: 'My Store', countries: ['US', 'GB'], requiredBillingContactFields: ['postalAddress'], requiredShippingContactFields: ['email', 'name'], }, }, callbacks: { onPaymentSucceeded: () => { window.location.href = '/success'; }, onPaymentFailed: (error) => console.error('Failed', error), onActionRequired: (url) => { window.location.href = url; }, }, }); checkout.elements.express.applePay.mount(applePayRef.current!); checkout.elements.error.mount(errorRef.current!); checkoutRef.current = checkout; fetch('/api/create-payment', { method: 'POST' }) .then((res) => res.json()) .then(({ paymentId, checkoutKey }) => checkout.associatePayment(paymentId, checkoutKey)); return () => checkoutRef.current?.destroy(); }, []); return (
); } ``` --- # Getting Started This guide walks you through integrating ODUS Elements into a React application — from installation to accepting your first payment. ## Prerequisites Before you begin, make sure you have: 1. A backend endpoint that creates payments via the ODUS API — see [Making Payments](https://docs.odus.com/docs/guides/payments/making-payments.md). ## How It Works ODUS Elements follows a three-step flow: 1. **Create** — Initialize `OdusElements` with your API key, environment, and callbacks. 2. **Mount** — Place individual UI elements (card input, email, error display, etc.) into your page using refs or CSS selectors. 3. **Associate & Authorize** — Link a server-side payment with `associatePayment()`, then call `authorize()` from your submit button. You choose which elements to mount. Only mounted elements participate in validation and authorization — unmounted elements are automatically skipped. This lets you build anything from a minimal card-only form to a full checkout with address fields and express payment buttons. ## Step 1: Install the SDK ``` npm install @odus/checkout@latest ``` If your app already depends on the SDK, make sure it resolves to version `1.12.0` or later before 4 November 2026. Version `1.12.0` is the first SDK release that calls only API v2. SDK versions that call API v1 routes stop working when v1 stops being served. ## Step 2: Create and Mount Elements Initialize `OdusElements` and mount elements into container refs: ``` import { OdusElements } from '@odus/checkout/elements'; const checkout = new OdusElements({ environment: 'test', apiKey: 'pk_retired', returnUrl: 'https://your-website.com/checkout/return', callbacks: { onPaymentSucceeded: () => { window.location.href = '/checkout/success'; }, onPaymentFailed: (error) => { console.error('Payment failed:', error); }, onActionRequired: (url) => { window.location.href = url; }, }, }); // Mount elements into DOM containers checkout.elements.contact.email.mount(emailRef.current!); checkout.elements.card.mount(cardRef.current!); checkout.elements.error.mount(errorRef.current!); ``` At this point the inputs render on the page, but no payment is linked yet. info Elements ship with built-in styles — no separate CSS import is needed. Each element injects its own scoped styles via Shadow DOM. ## Step 3: Associate a Payment and Authorize Your backend creates a payment via the ODUS API and returns a `paymentId` and `checkoutKey`. Call `associatePayment()` to link them, then `authorize()` when the customer submits. info Learn how to create payments on your backend in the [Making Payments](https://docs.odus.com/docs/guides/payments/making-payments.md) guide. ``` // Link the server-side payment const { paymentId, checkoutKey } = await fetch('/api/create-payment', { method: 'POST', }).then((res) => res.json()); await checkout.associatePayment(paymentId, checkoutKey); // On submit: validate, then authorize const { valid, errors } = checkout.validate(); if (valid) { await checkout.authorize(); } ``` ## Complete Example Here's a full React component tying all three steps together: src/components/CheckoutForm.tsx ``` import { useEffect, useRef, useState } from 'react'; import { OdusElements } from '@odus/checkout/elements'; export function CheckoutForm() { const checkoutRef = useRef(null); const emailRef = useRef(null); const cardRef = useRef(null); const errorRef = useRef(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [isFormValid, setIsFormValid] = useState(false); useEffect(() => { const checkout = new OdusElements({ environment: 'test', apiKey: 'pk_retired', returnUrl: 'https://your-website.com/checkout/return', callbacks: { onPaymentSucceeded: () => { window.location.href = '/checkout/success'; }, onPaymentFailed: (errorMessage) => { setError(`Payment failed: ${errorMessage}`); }, onActionRequired: (redirectUrl) => { window.location.href = redirectUrl; }, }, }); checkout.elements.contact.email.mount(emailRef.current!); checkout.elements.card.mount(cardRef.current!); checkout.elements.error.mount(errorRef.current!); checkout.onChange(() => { setIsFormValid(checkout.isValid); }); checkoutRef.current = checkout; async function setupPayment() { try { const response = await fetch('/api/create-payment', { method: 'POST', headers: { 'Content-Type': 'application/json' }, }); if (!response.ok) throw new Error('Failed to create payment'); const { paymentId, checkoutKey } = await response.json(); await checkout.associatePayment(paymentId, checkoutKey); } catch (err) { setError('Failed to initialize payment. Please refresh the page.'); } finally { setIsLoading(false); } } setupPayment(); return () => { checkoutRef.current?.destroy(); }; }, []); async function handlePay() { const checkout = checkoutRef.current; if (!checkout) return; const { valid, errors } = checkout.validate(); if (!valid) { console.log('Validation errors:', errors); return; } await checkout.authorize(); } return (
{isLoading &&

Loading checkout...

} {error &&

{error}

}
); } ``` tip Call `associatePayment()` as early as possible. This minimizes customer wait time and ensures express payment methods (Apple Pay, PayPal) render promptly. ## Updating the Payment When the customer changes their selected plan or pricing option after the form is already rendered, create a new payment on your backend and call `associatePayment()` again on the **same instance**. Do not destroy and recreate the Elements instance — the SDK preserves all user-entered form data and only refreshes the express payment buttons. src/components/CheckoutForm.tsx ``` const PRICES = [ { id: 'basic', label: 'Basic — $9/mo', amount: 900 }, { id: 'pro', label: 'Pro — $29/mo', amount: 2900 }, ]; export function CheckoutForm() { const checkoutRef = useRef(null); const [selectedPrice, setSelectedPrice] = useState(PRICES[0]); // Mount once — no dependency on selectedPrice useEffect(() => { const checkout = new OdusElements({ /* config */ }); checkout.elements.contact.email.mount(emailRef.current!); checkout.elements.card.mount(cardRef.current!); checkoutRef.current = checkout; return () => { checkoutRef.current?.destroy(); }; }, []); // Re-associate when the price changes useEffect(() => { const checkout = checkoutRef.current; if (!checkout) return; async function updatePayment() { const response = await fetch('/api/create-payment', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ amount: selectedPrice.amount }), }); const { paymentId, checkoutKey } = await response.json(); await checkout!.associatePayment(paymentId, checkoutKey); } updatePayment(); }, [selectedPrice]); return (
{PRICES.map((price) => ( ))}
); } ``` warning Do not destroy and recreate the Elements instance when the price changes. This creates new elements from scratch, which **wipes all fields** the customer has already filled in. ## Next Steps * [Configuration](https://docs.odus.com/docs/guides/checkout/elements/configuration.md) — Customize callbacks, localization, and additional payment methods * [Theming](https://docs.odus.com/docs/guides/checkout/elements/theming.md) — Style elements using CSS custom properties * [Examples](https://docs.odus.com/docs/guides/checkout/elements/examples.md) — Integration patterns: minimal, PayPal-only, custom payload, and more * [SDK Reference](https://docs.odus.com/docs/guides/checkout/elements/sdk-reference.md) — Full API reference for all classes and types * [3DS and Redirect Handling](https://docs.odus.com/docs/guides/checkout/3ds-and-redirect-handling.md) — Handle redirect-based flows like 3DS and PayPal * [Builder](https://docs.odus.com/docs/guides/checkout/elements/builder.md) — Drag and drop elements, adjust themes, and preview your layout in an interactive playground --- # SDK Reference Complete API reference for the `OdusElements` class, `BaseElement` class, and all TypeScript types. ## OdusElements Class ``` class OdusElements { constructor(config: ElementsConfig); readonly elements: { card: CardElement; cardholderName: CardholderElement; contact: { email: EmailElement; phone: PhoneElement; }; address: { firstName: AddressFirstNameElement; lastName: AddressLastNameElement; street: AddressStreetElement; city: AddressCityElement; state: AddressStateElement; zipCode: AddressZipCodeElement; country: AddressCountryElement; }; express: { applePay: ApplePayElement; payPal: PayPalElement; }; error: ErrorElement; }; associatePayment(paymentId: string, checkoutKey: string): Promise; validate(): ValidationResult; authorize(payload?: AuthorizePayload): Promise; setCallbacks(callbacks: Partial): void; applyTheme(theme: Record): void; resetTheme(theme: Record): void; onChange(listener: () => void): void; destroy(): void; readonly isValid: boolean; } ``` ### Constructor **`new OdusElements(config: ElementsConfig)`** Creates a new Elements instance. Element accessors on the `elements` namespace are lazily initialized — elements are created when first accessed. *** ## Available Elements ### Card Input **`elements.card`** — Combined card number, expiry and CVV input, rendered inside secure iFrames. The card number and security code stay inside those iFrames and never enter your page. `authorize()` exchanges them for a token and submits the payment for you; it returns no card data. If the card fields cannot load, the element shows an error and `authorize()` fails, rather than asking the shopper for a card number in your page. ### Cardholder Name **`elements.cardholderName`** — Cardholder name input (filtered to `[a-zA-Z\s\-'.]`). ### Contact **`elements.contact.email`** — Email address input with validation. **`elements.contact.phone`** — Phone number input with country dial code selector. ### Address | Element | Description | | ---------------------------- | -------------------------------------------------------------------------- | | `elements.address.firstName` | First name input | | `elements.address.lastName` | Last name input | | `elements.address.street` | Street address input | | `elements.address.city` | City input | | `elements.address.state` | State/province — auto-switches between text and dropdown for US, CA and AU | | `elements.address.zipCode` | Zip/postal code input | | `elements.address.country` | Country dropdown. Use `setCountries(countries)` to restrict options | ### Express Payments **`elements.express.applePay`** — Apple Pay button. Renders only after `associatePayment()` and only when the payment supports Apple Pay. Triggers the native payment sheet and completes authorization automatically. After the element is **mounted** and a payment has been associated (both must happen, since `isAvailable` is computed when the element renders), read the `applePay.isAvailable` property to decide whether to show the button's surrounding layout (heading, divider, or "or pay with" separator). On browsers that don't support Apple Pay — or when the payment's country isn't allowed — nothing is rendered and the element's host collapses, so you don't need to measure the button's height to detect availability. ``` await elements.associatePayment(paymentId, checkoutKey); const applePay = elements.express.applePay; applePay.mount('#apple-pay-slot'); // the SDK collapses this slot itself when unavailable // Read isAvailable only after mounting, then hide your OWN surrounding chrome // (heading, "or pay with" divider, padding) — the SDK can't see or collapse that: document.querySelector('#apple-pay-container').hidden = !applePay.isAvailable; ``` **`elements.express.payPal`** — PayPal button. Renders only after `associatePayment()` and only when the payment supports PayPal. Triggers authorization and redirects via `onActionRequired`. ### Error Display **`elements.error`** — Shows API-level payment errors automatically. Use `setError(message)` and `clearError()` for manual control. *** ## OdusElements Methods #### associatePayment ``` associatePayment(paymentId: string, checkoutKey: string): Promise ``` Links the Elements instance to a payment created on your backend. This fetches checkout details, stores payment state, and configures express payment methods (Apple Pay, PayPal). Express payment buttons only render after this call succeeds. Can be called multiple times on the same instance — all user-entered form data is preserved, and only the express payment buttons are refreshed. This is the recommended way to handle price or plan changes without losing form state. If the call rejects, the payment is **not** associated: `authorize()` keeps throwing until a later `associatePayment()` succeeds. A rejection also discards a previous successful association, so retry it before you authorize. #### validate ``` validate(): ValidationResult ``` Validates all **mounted** elements and returns a `ValidationResult`. Unmounted elements are skipped. ``` const { valid, errors } = checkout.validate(); // errors: Record — key is element name, value is error message ``` #### authorize ``` authorize(payload?: AuthorizePayload): Promise ``` Authorizes the payment. Requires a prior `associatePayment()` call. The optional `payload` parameter allows passing merchant-collected data (e.g., email, name) that supplements or overrides element-derived values. `authorize()` charges the card, so mount `elements.card` before you call it. The only exception is Apple Pay: once the sheet has authorized, `authorize()` submits that instead. If there is no card to charge, or the card details cannot be tokenized, no request is sent — the reason goes to `onPaymentFailed` and to a mounted `elements.error`. ``` await checkout.authorize({ customerData: { email: 'user@example.com', name: 'John Doe', }, }); ``` #### applyTheme ``` applyTheme(theme: Record): void ``` Applies CSS custom property overrides to all instantiated elements. See [Theming](https://docs.odus.com/docs/guides/checkout/elements/theming.md) for available variables. #### resetTheme ``` resetTheme(theme: Record): void ``` Removes the specified CSS custom property overrides from all instantiated elements, reverting them to defaults. #### onChange ``` onChange(listener: () => void): void ``` Registers a listener that fires whenever any mounted element emits a `change` event. Useful for tracking form validity. ``` checkout.onChange(() => { setIsFormValid(checkout.isValid); }); ``` #### setCallbacks ``` setCallbacks(callbacks: Partial): void ``` Updates one or more payment lifecycle callbacks after initialization. Only the provided callbacks are replaced; omitted callbacks remain unchanged. This eliminates stale closure issues in reactive frameworks where callbacks capture state at mount time. ``` // React example — update callbacks when dependencies change useEffect(() => { checkoutRef.current?.setCallbacks({ onPaymentSucceeded: () => trackPurchase(priceId, quantity), }); }, [priceId, quantity]); ``` #### destroy ``` destroy(): void ``` Destroys all elements and clears all listeners. Call this when unmounting your checkout component. ### Properties #### isValid ``` readonly isValid: boolean ``` Shorthand for `validate().valid`. Returns `true` if all mounted elements are valid. *** ## BaseElement Class All individual elements extend `BaseElement`. This is the shared API available on every element. ``` abstract class BaseElement { mount(target: string | HTMLElement): void; unmount(): void; destroy(): void; on(event: ElementEvent, handler: (data?: unknown) => void): this; update(options: Partial): void; setTheme(theme: Record): void; resetTheme(theme: Record): void; readonly value: unknown; readonly isValid: boolean; readonly error: string | null; readonly isMounted: boolean; } ``` ### Methods #### mount ``` mount(target: string | HTMLElement): void ``` Creates a Shadow DOM host element, appends it to the target (CSS selector or DOM element), renders the element UI, and emits a `ready` event. #### unmount ``` unmount(): void ``` Removes the host element from the DOM and clears the shadow root. #### destroy ``` destroy(): void ``` Calls `unmount()` and clears all registered event listeners. #### on ``` on(event: ElementEvent, handler: (data?: unknown) => void): this ``` Registers an event listener. Returns `this` for chaining. ``` checkout.elements.card.on('change', () => console.log('Card changed')).on('blur', () => console.log('Card blurred')); ``` #### update ``` update(options: Partial): void ``` Merges new options into the element's current options and re-renders if the element is mounted. #### setTheme ``` setTheme(theme: Record): void ``` Sets CSS custom properties on this element's host element, overriding the global theme for this element only. #### resetTheme ``` resetTheme(theme: Record): void ``` Removes CSS custom properties from this element's host element. ### Properties | Property | Type | Description | | ----------- | ---------------- | --------------------------------------------------------- | | `value` | `unknown` | The current value of the element (type varies by element) | | `isValid` | `boolean` | Whether the element's current value passes validation | | `error` | `string \| null` | The current validation error message, or `null` | | `isMounted` | `boolean` | Whether the element is currently mounted in the DOM | *** ## Types ### ElementsConfig ``` type ElementsConfig = { apiKey: string; environment: 'test' | 'live'; returnUrl: string; locale?: Locale; callbacks?: OdusElementsCallbacks; additionalPaymentMethods?: AdditionalPaymentMethodsConfig; }; ``` ### OdusElementsCallbacks ``` type OdusElementsCallbacks = { onPaymentSucceeded?: (result: PaymentResult) => void; onPaymentFailed?: (result: string) => void; onActionRequired?: (redirectUrl: string) => void; onLoadingStateChange?: (isLoading: boolean) => void; }; ``` ### MountOptions ``` type MountOptions = { locale?: string; disabled?: boolean; initialValue?: unknown; maxLength?: number; callbacks?: Partial void>>; }; ``` ### ElementEvent ``` type ElementEvent = 'change' | 'blur' | 'focus' | 'error' | 'ready'; ``` ### ValidationResult ``` type ValidationResult = { valid: boolean; errors: Record; }; ``` ### AuthorizePayload ``` type AuthorizePayload = { customerData?: { email?: string; name?: string; phoneNumber?: string; dateOfBirth?: string; gender?: string; billingAddress?: Record; shippingAddress?: Record; [key: string]: unknown; }; }; ``` ### AuthorizeResult ``` type AuthorizeResult = { status: 'succeeded'; data: PaymentSuccessResult } | { status: 'action_required'; action: ActionRequiredData } | { status: 'failed'; error: PaymentError }; ``` ### OdusTheme A `Record` mapping CSS custom property names to values. See [Theming](https://docs.odus.com/docs/guides/checkout/elements/theming.md) for the full list of available variables. ### ApplePayConfig ``` type ApplePayConfig = { displayName?: string; countries?: string[]; requiredBillingContactFields?: ApplePayContactField[]; requiredShippingContactFields?: ApplePayContactField[]; }; ``` ### AdditionalPaymentMethodsConfig ``` type AdditionalPaymentMethodsConfig = { applePay?: ApplePayConfig; }; ``` ### Locale ``` type Locale = 'en' | 'de' | 'es' | 'fr' | 'pl' | 'pt' | 'it' | 'tr' | 'cs' | 'nl' | 'sk'; ``` --- # Theming ODUS Elements uses CSS custom properties for styling, giving you fine-grained control over every element's visual appearance. Each element renders inside its own Shadow DOM and inherits theme variables from CSS custom properties set on its host element. You can apply a theme in two ways: * **Globally** — `applyTheme()` sets variables on all instantiated elements at once. * **Per-element** — `setTheme()` overrides the global theme for a single element. ## Theme Variables The following CSS custom properties are available, shown with their default values: ### Font | Variable | Default | Description | | -------------------- | ----------------------- | --------------------------------- | | `--odus-font-family` | `system-ui, sans-serif` | Base font family for all elements | | `--odus-font-size` | `16px` | Base font size | When you set `--odus-font-family` via `applyTheme()`, the corresponding Google Font is automatically loaded by injecting a `` stylesheet into the document head. The font is removed when you call `resetTheme()` with `--odus-font-family` or when the checkout instance is destroyed. Supported font families: * `Roboto` * `Open Sans` * `Lato` * `Montserrat` * `Poppins` * `Inter` * `Noto Sans` * `Source Sans Pro` * `Raleway` * `Ubuntu` * `Work Sans` * `Nunito` * `Quicksand` * `Rubik` * `DM Sans` ### Colors | Variable | Default | Description | | ------------------------- | --------- | ------------------ | | `--odus-color-error` | `#dc2727` | Error state color | | `--odus-color-text` | `#1a1a1a` | Default text color | | `--odus-color-background` | `#ffffff` | Background color | ### Borders | Variable | Default | Description | | ---------------------- | --------- | ------------------------------------ | | `--odus-border-color` | `#e0e0e0` | Default border color for inputs | | `--odus-border-radius` | `6px` | Border radius for inputs and buttons | ### Spacing | Variable | Default | Description | | ------------------- | ------- | ------------------- | | `--odus-spacing-sm` | `8px` | Small spacing unit | | `--odus-spacing-md` | `16px` | Medium spacing unit | | `--odus-spacing-lg` | `24px` | Large spacing unit | ### Inputs | Variable | Default | Description | | ---------------------------------- | ----------------------- | --------------------------------- | | `--odus-input-height` | `44px` | Input field height | | `--odus-input-padding` | `8px 12px` | Input field padding | | `--odus-input-font-size` | `var(--odus-font-size)` | Input text font size | | `--odus-input-font-weight` | `400` | Input text font weight | | `--odus-input-letter-spacing` | `0.02em` | Input text letter spacing | | `--odus-input-line-height` | `1.5` | Input text line height | | `--odus-input-placeholder-color` | `#717173` | Placeholder text color | | `--odus-input-placeholder-opacity` | `0.3` | Placeholder text opacity | | `--odus-input-focus-shadow` | blue glow ring | Box shadow applied on input focus | ### Labels | Variable | Default | Description | | ----------------------------- | --------- | ----------------------------- | | `--odus-label-font-size` | `14px` | Label font size | | `--odus-label-font-weight` | `500` | Label font weight | | `--odus-label-color` | inherited | Label text color | | `--odus-label-letter-spacing` | inherited | Label letter spacing | | `--odus-label-line-height` | `1.4` | Label line height | | `--odus-label-margin-bottom` | `6px` | Space between label and input | ### Validation messages | Variable | Default | Description | | -------------------------------- | ------- | ------------------------------------- | | `--odus-helper-text-line-height` | `14px` | Height of one validation message line | | `--odus-helper-text-space` | `4px` | Space reserved below a field for it | A validation message is drawn below its field but outside the layout, so an element never changes height when one appears or clears and nothing on your page moves. Each field reserves only `--odus-helper-text-space` — a thin strip that keeps the message off whatever follows — and the message itself overhangs into the gap you already have between fields. That overhang paints over what sits beneath it, on the form's own background colour and without intercepting clicks, so allow roughly `16px` below each element. Every message we ship fits one line at usual widths, but a narrow element can still wrap one onto a second line. Errors and hints (such as the email "did you mean" suggestion) share one row per field, so a field looks the same height whichever it shows. ### Card & Button | Variable | Default | Description | | ----------------------------- | --------------------------- | ------------------------------------------------- | | `--odus-card-gap` | `12px` | Gap between card sub-fields (number, expiry, CVV) | | `--odus-button-height` | `48px` | Express button height | | `--odus-button-border-radius` | `var(--odus-border-radius)` | Express button border radius | ## Applying a Theme ### Global Theme Use `applyTheme()` to set theme variables on all instantiated elements at once: ``` checkout.applyTheme({ '--odus-color-text': '#f8fafc', '--odus-color-background': '#1e293b', '--odus-border-color': '#334155', '--odus-border-radius': '8px', '--odus-font-family': 'Inter', }); ``` To remove specific theme overrides and revert to defaults: ``` checkout.resetTheme({ '--odus-color-background': '', }); ``` ### Per-Element Theme Override theme variables on a single element using `setTheme()` on the element instance: ``` checkout.elements.card.setTheme({ '--odus-border-color': '#6366f1', '--odus-input-height': '52px', }); // Reset to defaults checkout.elements.card.resetTheme({ '--odus-border-color': '', '--odus-input-height': '', }); ``` ## Example: Dark Theme ``` const darkTheme = { '--odus-color-error': '#f87171', '--odus-color-text': '#f1f5f9', '--odus-color-background': '#0f172a', '--odus-border-color': '#334155', '--odus-border-radius': '10px', '--odus-input-placeholder-color': '#94a3b8', '--odus-input-placeholder-opacity': '0.6', '--odus-input-focus-shadow': '0 0 0 2px rgba(129, 140, 248, 0.4)', '--odus-label-color': '#cbd5e1', }; checkout.applyTheme(darkTheme); ``` tip Because Elements uses CSS custom properties, you can also set these variables in your own CSS stylesheets or via inline styles on the container elements. The Shadow DOM will inherit the values through the host element. tip Want to experiment with themes visually? Try the interactive [Elements Builder](https://docs.odus.com/docs/guides/checkout/elements/builder.md) to preview theme changes in real time and export the configuration. --- # Migration to Checkout V1 (iFrame fields) The Checkout SDK renders card payment fields inside secure iFrames instead of native browser inputs. This change reduces your PCI DSS scope — sensitive card data (card number, expiry, CVV, and cardholder name) no longer enters your page. important Native card inputs will be fully removed on **June 3, 2026**. After that date, all checkout sessions will use iFrame-based fields exclusively. ## Is action required? Update your SDK dependency to the latest release: ``` npm install @odus/checkout@latest ``` Use version `1.12.0` or later before 4 November 2026. Version `1.12.0` is the first SDK release that calls only API v2. SDK versions that call API v1 routes stop working when v1 stops being served. Beyond the version bump, the iFrame migration is automatic and server-managed. Your existing SDK initialization code, callbacks, and payment flow remain unchanged: * `OdusCheckout` / `OdusElements` — same constructor options * `mount()` — same behavior * `associatePayment()` — same behavior * `onPaymentSucceeded` / `onPaymentFailed` — same callback signatures If you have not customized the checkout beyond the documented `appearance` configuration, no additional changes are needed. ## What changed | Aspect | Before (native inputs) | After (iFrame fields) | | ----------------------------------------- | -------------------------------------------------------- | --------------------------------------------------- | | Card number, expiry, CVV, cardholder name | Rendered as `` elements directly in your page DOM | Rendered inside secure iFrames managed by ODUS | | PCI scope | Card data passes through your page | Card data is isolated — never touches your page | | Field loading | Immediate | Brief skeleton placeholder while iFrames initialize | ## Behavioral differences You and your customers may notice these minor UX changes: 1. **Loading skeleton** — Card fields display a brief placeholder animation while iFrames load. This typically resolves within 1–2 seconds on a standard connection. ## Styling considerations If your integration relies on custom CSS that directly targets card input elements (e.g., styling `.form-input` or `input[name="cardNumber"]`), those selectors will no longer reach the fields inside iFrames. Instead, use the SDK's built-in styling options: * **Drop-in Checkout** — configure `appearance.styles` (borderRadius, fontFamily, fontSize, textColor) as documented in the [Appearance guide](https://docs.odus.com/docs/guides/checkout/drop-in/appearance.md) * **Elements** — use `applyTheme()` or per-element `setTheme()` as documented in the [Theming guide](https://docs.odus.com/docs/guides/checkout/elements/theming.md) tip The `appearance.styles.borderRadius`, `appearance.styles.fontFamily`, and `appearance.styles.fontSize` options are fully applied to iFrame fields. If your checkout already uses these options, the fields will look identical to before. --- # Customers Customers are reusable records in ODUS that represent the people or organizations you charge. Each customer holds contact information and serves as the central reference point for payments, subscriptions, and saved payment methods. This section explains how customer records are created, how to find and update them, and how to control which payment types a customer is allowed to make. ## Guides * [Managing customers](https://docs.odus.com/docs/guides/customers/managing-customers.md) — How customers are created, and how to find and update them via the API and the Dashboard. * [Customer restrictions](https://docs.odus.com/docs/guides/customers/customer-restrictions.md) — Restrict a customer from making customer-initiated or merchant-initiated payments. --- # Restrictions Customer restrictions let you block a specific customer from making payments. You can block new payment attempts made by the customer (CIT — customer-initiated transactions), block recurring subscription charges (MIT — merchant-initiated transactions), or both. Restrictions are separate from the Blacklist. Restrictions target a customer by their ID and can be turned on or off independently per transaction type. The Blacklist targets customers by email address and is described in the [Blacklist](#the-blacklist) section below. ## Prerequisites * A secret API key with the `customer:manage_restrictions` permission. To work with the Blacklist through the API, the key also needs `customer_blacklist:read`, `customer_blacklist:create` or `customer_blacklist:delete`, depending on the request. * The customer ID you want to restrict (`cus_…`). ## Apply a Restriction Send a [`POST /v2/customers/{id}/restrictions`](https://docs.odus.com/docs/api/core/v2/customer-controller-manage-restrictions) request with the restriction flags you want to enable: ``` { "customer": "cus_2R0uWz4tu9wxTMBXxj9CJfSE9vV", "restrictCit": true, "restrictMit": true, "restrictReason": "Account suspended pending fraud review" } ``` Set `restrictCit` to block customer-initiated payments, `restrictMit` to block merchant-initiated payments, or both. The optional `restrictReason` stores a free-text explanation on the record. Setting only one flag allows the other transaction type to proceed normally. See the [`POST /v2/customers/{id}/restrictions`](https://docs.odus.com/docs/api/core/v2/customer-controller-manage-restrictions) reference for the full parameter list. Example To block a customer from making new checkout payments without stopping their active subscriptions, set only `restrictCit`: ``` { "customer": "cus_2R0uWz4tu9wxTMBXxj9CJfSE9vV", "restrictCit": true, "restrictMit": false } ``` ## Apply a Restriction via the Dashboard 1. Open the **Customers** section and select the customer. 2. The customer detail page shows a red **Restricted** chip next to the customer name when a restriction is already active. 3. Click the **Actions** button (top right of the customer card) and select **Restrict customer**. 4. In the dialog, fill in the **Restrict reason** field (optional). 5. Toggle **Restrict customer initiated** to block CIT payments, and/or **Restrict merchant initiated** to block MIT payments. 6. Click **Restrict** to save. ## Remove a Restriction To lift all restrictions, send the same endpoint with both flags set to `false`: ``` { "customer": "cus_2R0uWz4tu9wxTMBXxj9CJfSE9vV", "restrictCit": false, "restrictMit": false } ``` To remove only one type of restriction, set its flag to `false` while leaving the other unchanged. Via the Dashboard, follow the same steps as applying a restriction, but toggle the relevant switches off before clicking **Restrict**. ## The `isRestricted` Field The customer object includes an `isRestricted` boolean field. It is `true` when at least one of `restrictCit` or `restrictMit` is `true`. It is `false` only when both flags are `false`. You can use `isRestricted` as a single check when you do not need to know which type of restriction is active. warning The `restrictCit`, `restrictMit`, `restrictReason`, and `isRestricted` fields are only visible when authenticated with a secret key or accessed through the Dashboard. They are not returned to a checkout key. ## Automatic Restrictions from Chargebacks Recording a chargeback adds the customer's email to the [Blacklist](#the-blacklist) while [**Blacklist customer on chargeback**](https://docs.odus.com/docs/guides/dashboard/merchant-configuration.md#blacklist-customer-on-chargeback) is on, which it is by default. With the setting off, set `blacklistCustomer` to blacklist the customer for one chargeback. Blacklisting does not change the customer's `restrictCit` or `restrictMit` flags; set those with `customerRestrictions` in the same request. See [Handling Chargebacks](https://docs.odus.com/docs/guides/payments/chargebacks.md) for details. ## The Blacklist The Blacklist is a separate mechanism that blocks customers by email address rather than by customer ID. When a customer whose email is on the Blacklist attempts a new payment, the payment is rejected regardless of whether their customer record has `restrictCit` set. ### View the Blacklist Navigate to the **Blacklist** page in the Dashboard to see all blacklisted email addresses, their reason (if provided), and the date they were added. ### Check whether an email is blacklisted Filter the Blacklist by email: ``` GET /v2/blacklist?email[eq]=user@example.com ``` An empty `data` array means the email is not on the Blacklist. Emails are stored and compared in lowercase, so the case you send does not matter. ### Add an Entry to the Blacklist You can add an email address to the Blacklist through the Dashboard or the API. **Dashboard:** Click the **Add** button on the Blacklist page, enter the customer's email address, add an optional reason, and click **Submit**. **API:** Send a [`POST /v2/blacklist`](https://docs.odus.com/docs/api/core/v2/blacklist-controller-create) request: ``` { "email": "user@example.com", "reason": "Fraudulent activity detected" } ``` Blacklisting cancels the customer's subscriptions When you add an email to the Blacklist, ODUS also cancels **immediately** every active subscription of every customer with that email. Their invoices are not changed: an invoice whose payment is already in progress is settled by that payment, and no new charge is attempted, because the Blacklist refuses it. This applies however the entry is added: from the Dashboard, through the API, or by recording a chargeback with `blacklistCustomer`. The cancellations follow within moments of the entry being added, not in the response to the request that added it. Each subscription sends a `subscription.cancelled` event and carries `cancellationReason: "customer_blacklisted"`. Removing the entry later does not restore them; create a new subscription instead. Adding an email that is already on the Blacklist changes nothing. ### Remove an Entry from the Blacklist **Dashboard:** On the Blacklist page, click the delete action next to the entry you want to remove and confirm. **API:** Send a [`POST /v2/blacklist/{id}/delete`](https://docs.odus.com/docs/api/core/v2/blacklist-controller-delete-entry) request using the blacklist entry ID (`bl_…`). No request body is required. note Removing an entry from the Blacklist does not automatically remove `restrictCit` or `restrictMit` flags from the customer record. Use [`POST /v2/customers/{id}/restrictions`](https://docs.odus.com/docs/api/core/v2/customer-controller-manage-restrictions) to clear those independently if needed. ## CIT vs MIT: Key Distinction | Flag | Blocks | | ------------- | ------------------------------------------------------------------------------------------- | | `restrictCit` | New payment attempts initiated by the customer (e.g. checkout sessions, one-time purchases) | | `restrictMit` | Recurring charges triggered by the merchant (e.g. scheduled subscription renewals) | The two flags operate independently. A customer with only `restrictMit` set can still make new purchases; a customer with only `restrictCit` set will still be charged for their active subscriptions. --- # Managing Customers A customer record in ODUS represents a person or organization you charge. Each record holds contact information — name, email, phone number, and addresses. Payments, subscriptions, and saved payment methods reference the customer by its `id` (prefixed `cus_`). ## Prerequisites * A valid ODUS secret key with the `customer:update` permission to update customers, and `customer:read` to find them. ## How customers are created v2 has no endpoint for creating a customer. A customer record is created when a shopper pays through the [Checkout SDK](https://docs.odus.com/docs/guides/checkout/.md), from the details collected at checkout. To charge a returning customer, pass their `id` as `customer` when you [create a payment](https://docs.odus.com/docs/guides/payments/making-payments.md). To find that `id`, [look the customer up](#find-and-filter-customers) by email. ## Update a customer Send a [`POST /v2/customers/{id}`](https://docs.odus.com/docs/api/core/v2/customer-controller-update) request with the fields you want to change. All fields are optional — only the fields you include will be updated. ``` { "name": "Jane Doe", "phoneNumber": "+15559876543" } ``` ### Updating metadata The `metadata` field is merged with any existing metadata on every update. Only the keys you include are changed — existing keys that you do not mention are preserved. To remove a specific key, set its value to an empty string. To clear all metadata at once, send `null`. ``` { "metadata": { "internalAccountId": "acct_9870", "tier": "premium" } } ``` note Metadata is for your own use only. ODUS does not use metadata values in any processing logic. ## View a customer in the Dashboard 1. In the Dashboard, go to **Customers**. 2. Click a customer row to open the customer detail page. The detail page shows: * **Subscriptions** — all subscriptions linked to this customer. * **Payments** — all payment attempts associated with this customer. * **Payment methods** — saved cards and other payment methods stored for this customer. * **Customer details** — contact information and editable metadata, displayed in the right sidebar. To edit the customer's name, email, phone number, or address, click **Edit** on the customer card at the top of the page. To remove a stored phone number, clear the field and save. To edit metadata directly in the Dashboard, use the metadata editor in the **Customer details** sidebar and save your changes. ## Find and filter customers ### Dashboard On the **Customers** list page, use the filter bar to narrow results: | Filter | Description | | ------------ | --------------------------------------- | | Email | Exact or partial match on email address | | Name | Exact or partial match on display name | | Created Date | Filter by creation date range | Click any row to open that customer's detail page. ### API Use [`GET /v2/customers`](https://docs.odus.com/docs/api/core/v2/customer-controller-find-all-paginated) with query parameters to filter results. Text filters such as `email` and `name` support `eq`, `ne`, `like`, `ilike`, `in` and `nin`. `id` is matched whole and accepts `eq`, `ne`, `in` and `nin`. Look a customer up by email with `eq`. ODUS stores email addresses in lowercase and lowercases the value you send, so the case does not matter: ``` GET /v2/customers?email[eq]=jane@example.com ``` Every other string filter is case-sensitive, and `like` matches the start of a value. To match any part of a value in any case, use `ilike` and wrap the value in `%` wildcards, encoded as `%25`: ``` GET /v2/customers?name[ilike]=%25smith%25&sortDirection=desc&limit=20 ``` warning A filter whose case does not match the stored value returns an empty page, not an error. Use `ilike` for names and other values people type. The response is paged by cursor. To fetch the next page, send the returned `pagination.nextCursor` as `cursor`. See [Pagination, Filtering, and Sorting](https://docs.odus.com/docs/guides/api-features/pagination-filtering-sorting.md#customers) for the full list of supported filter fields and operators. ## Read a customer's payment methods and subscriptions A v2 customer object does not include the customer's payment methods or subscriptions. Read each from its own list endpoint, filtered by the customer's `id`: ``` GET /v2/payment-methods?customer[eq]=cus_2R0uWz4tu9wxTMBXxj9CJfSE9vV GET /v2/subscriptions?customer[eq]=cus_2R0uWz4tu9wxTMBXxj9CJfSE9vV ``` Your key needs `payment_method:read` and `subscription:read` for these requests. --- # Dashboard The ODUS Dashboard is a web interface that allows you to manage your account. Learn how to use the Dashboard to configure your settings. Most dashboard operations can also be performed via the API assuming your Secret API Key has the necessary permissions. * [Gateway Profiles](https://docs.odus.com/docs/guides/dashboard/gateway-profiles.md) * [Merchant Configuration](https://docs.odus.com/docs/guides/dashboard/merchant-configuration.md) * [Team Management](https://docs.odus.com/docs/guides/dashboard/team-management.md) * [Analytics](https://docs.odus.com/docs/guides/dashboard/analytics.md) * [Request Logs](https://docs.odus.com/docs/guides/dashboard/request-logs.md) * [Webhook Subscriber](https://docs.odus.com/docs/guides/dashboard/webhook-subscriber.md) --- # Analytics The ODUS Dashboard home page shows a real-time summary of your payment activity. It combines a key metrics strip, a customizable grid of revenue and insight widgets, and a monthly rebills calendar. The metrics and widgets are driven by shared date range, granularity, and timezone controls, and you choose which widgets to show, how they are arranged, and how each chart is drawn. ## Prerequisites * You have access to the ODUS Dashboard. * Your account timezone is configured. The charts use the timezone set in **Settings > Merchant Configuration**. See [Merchant Configuration](https://docs.odus.com/docs/guides/dashboard/merchant-configuration.md) for how to change it. ## Step 1: Set the date range and timezone At the top of the page, open the date range selector. You can choose a preset range or enter custom start and end dates. **Preset ranges available:** | Preset | Description | | --------------- | ---------------------------------------- | | Today | Current calendar day | | Yesterday | Previous calendar day | | Last 7 days | The 7 days ending yesterday | | Last 4 weeks | The 4 full weeks ending last week | | Last 6 months | The 6 full months ending last month | | Last 12 months | The 12 full months ending last month | | Month to date | Start of the current month until today | | Quarter to date | Start of the current quarter until today | | Year to date | Start of the current year until today | Inside the date range selector you can also switch the **timezone** between: * **Your merchant timezone** — shown as its UTC offset (for example `-07:00`), configured in Merchant Configuration * **UTC** — Coordinated Universal Time All chart labels and totals reflect the selected timezone. ## Step 2: Choose a granularity Next to the date range selector, choose how to group the chart data. Available options are: | Granularity | Available when | | ----------- | ------------------------------------------ | | Hour | Date range is a single day (only option) | | Day | Date range is up to 3 months | | Week | Date range is between 2 weeks and 1 year | | Month | Date range is between 2 months and 2 years | | Quarter | Date range is at least 6 months | | Year | Date range is at least 1 year | Options that do not apply to the selected date range are disabled. The granularity affects only the revenue charts — the insight widgets (Product Sales, Billing Summary, Top Gateway Accounts) always show totals for the full period. tip When the selected date range is a single day, the **Hour** granularity is selected automatically and the charts render 24 hourly buckets (`00:00`–`23:00`) so they stay informative. ## Step 3: Read the key metrics Below the controls, a strip of four cards summarizes the selected period: * **Net Revenue** — revenue after refunds and chargebacks * **First Charge** — revenue from initial customer payments * **Recurring** — revenue from subscription rebills * **Refunds** — total amount refunded Each card also shows how the value changed compared to the previous period of the same length. The revenue charts include the same comparison as a **Previous period** series — a dashed line on area charts, and a lighter bar series on bar charts. ## Step 4: Read the widgets Below the key metrics, the dashboard shows a grid of widgets. By default it contains **Gross First Charge Revenue**, **Gross Recurring Revenue**, **Product Sales**, **Billing Summary**, and **Top Gateway Accounts**. You can add more widgets or remove any of them — see [Step 5](#step-5-customize-the-widget-grid). ### Revenue charts Revenue charts plot an amount over time, using the selected date range, granularity, and timezone. The large number at the top of each chart is the total across the entire date range. **Gross revenue** charts count revenue before refunds and chargebacks: | Widget | What it shows | | -------------------------- | ------------------------------------------ | | Total Gross Revenue | All revenue before refunds and chargebacks | | Gross First Charge Revenue | Revenue from initial customer payments | | Gross Recurring Revenue | Revenue from subscription rebills | **Net revenue** charts subtract refunds and chargebacks: | Widget | What it shows | | ------------------------ | -------------------------------------------------- | | Total Net Revenue | All revenue minus refunds and chargebacks | | Net First Charge Revenue | First charge revenue minus refunds and chargebacks | | Net Recurring Revenue | Recurring revenue minus refunds and chargebacks | **Revenue deductions** charts track amounts you lose: | Widget | What it shows | | ----------- | ------------------------------ | | Refunds | Total refund amounts over time | | Chargebacks | Chargeback volume over time | ### Insight widgets Insight widgets show totals for the full selected period. They are not affected by the granularity setting. | Widget | What it shows | | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | | Product Sales | Your products ranked by the number of successful first charges | | Billing Summary | A breakdown of the period totals: first charge revenue, recurring revenue, refunds, chargebacks, and the resulting gross and net revenue | | Top Gateway Accounts | Your gateway accounts ranked by gross revenue | tip Rows in the Billing Summary and Top Gateway Accounts widgets are clickable. Clicking a row opens a pre-filtered payment list, so you can inspect the payments behind each number. note The Product Sales widget counts only successful first charges. Recurring revenue per product is not shown here. ## Step 5: Customize the widget grid The widget grid is fully customizable: 1. **Add widgets.** Click **Add** in the toolbar. A dialog lists all available widgets, grouped into revenue charts (gross revenue, net revenue, revenue deductions) and insights. Select one or more widgets and click **Add**. Widgets that are already on the dashboard are greyed out. 2. **Rearrange or remove widgets.** Click **Edit** in the toolbar (adding a widget also opens this mode). Drag a widget to reorder it, or click the **×** in its corner to remove it. 3. **Save or discard.** Click **Done** to save the new layout, or **Cancel** to discard your changes. You can also adjust how the grid is drawn at any time, without entering edit mode: * **Switch a chart's visualization.** Hover over a revenue chart and use the switcher in its top-right corner to toggle between an **area** chart and a **bar** chart. * **Change the column count.** Use the grid toggle in the toolbar to show widgets in 2 or 3 columns (available on desktop screens). warning The dashboard layout is saved in your browser, not on your account. It does not follow you to other browsers or devices, and clearing your browser data resets the dashboard to its default layout. ## Step 6: Read the Rebills Calendar Below the widgets, the **Rebills Calendar** shows a monthly view of your recurring payment activity. Navigate between months using the arrow controls at the top of the calendar. Each day cell can show up to five event types: | Badge | Colour | Meaning | | ------------- | ------ | ----------------------------------------------------------- | | Rebills | Purple | Upcoming subscription renewals due on this day | | Retries | Blue | Upcoming automatic retries for previously failed payments | | Successes | Green | Recurring payments that succeeded on this day | | Soft Declines | Amber | Recurring payments declined with a soft decline on this day | | Hard Declines | Red | Recurring payments declined with a hard decline on this day | tip Click any badge to open a pre-filtered list of the relevant payments or subscriptions. This lets you investigate a specific day without manually applying filters. A **Rebills** count on a future date tells you how many subscriptions will attempt to renew that day. A **Retries** count tells you how many failed payments are queued for retry. note The calendar always shows exactly one month. Unlike the revenue charts, it is not affected by the date range selector at the top of the page. ## API reference The Dashboard uses two API endpoints. You can query them directly if you need to build your own reports or integrations. ### Key metrics and widgets ``` GET /v2/reports/payment-summary ``` Powers the key metrics strip and all widgets. Accepts `groupBy`, `measures`, date filters, and a `granularity` parameter. The Dashboard requests it with different groupings: | Widgets | `groupBy` | `measures` | | -------------------------------------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Key metrics strip, revenue charts, Billing Summary | `createdAt` | `fcRevenue`, `recurringRevenue`, `refundAmount`, `chargebackAmount`, `fcRefundAmount`, `fcChargebackAmount`, `recurringRefundAmount`, `recurringChargebackAmount`, `totalGrossRevenue`, `totalNetRevenue` | | Product Sales | `product.name` | `fcSuccessfulCount` | | Top Gateway Accounts | `gatewayProfile` | `totalGrossRevenue` | See [`GET /v2/reports/payment-summary`](https://docs.odus.com/docs/api/core/v2/reporting-controller-get-payment-summary-report) for the full parameter reference. ### Rebills Calendar ``` GET /v2/statistics/calendar ``` Returns per-day counts for upcoming recurring payments, upcoming retries, successful rebills, soft declines, and hard declines. Requires a `startDate` query parameter and accepts an optional `endDate`. See [`GET /v2/statistics/calendar`](https://docs.odus.com/docs/api/core/v2/statistics-controller-get-calendar-data) for the response schema. --- # Gateway Profiles A gateway profile is a named set of credentials for one payment gateway connection. For example, your Stripe live account and your Stripe test account are each a separate profile. You can create multiple profiles for the same gateway — for example, one per country or business unit. Gateway profiles are a prerequisite for: * Configuring cascade rules * Setting a default gateway in Merchant Configuration ## Prerequisites Before creating a gateway profile, obtain the required credentials from your payment gateway. Each gateway has its own setup guide: * [Stripe](https://docs.odus.com/docs/guides/integrations/gateways/stripe/.md) * [Adyen](https://docs.odus.com/docs/guides/integrations/gateways/adyen/.md) * [Checkout.com](https://docs.odus.com/docs/guides/integrations/gateways/checkout/.md) * [PayPal](https://docs.odus.com/docs/guides/integrations/gateways/paypal/.md) ## Create a gateway profile 1. In the Dashboard, go to **Cascades > Gateways**. 2. Click **Add**. 3. Fill in the form: * **Title** — a descriptive name for this profile, for example `Stripe — EU live`. * **Integration** — the gateway to connect to: `stripe`, `adyen`, `checkout`, or `paypal`. * **Environment** — `live` or `test`. This must match the type of credentials you are entering. 4. Enter the gateway-specific credentials. See [Gateway credentials](#gateway-credentials) below. 5. Click **Add**. After the profile is created, a **Setup Webhooks** dialog appears. You can enter your webhook verification key immediately or skip this step and configure it later. See [Set up webhooks](#set-up-webhooks). You can also create a profile via the API: [`POST /v2/gateway-profiles`](https://docs.odus.com/docs/api/core/v2/gateway-profile-controller-create). ## Gateway credentials The fields required depend on the gateway you selected. ### Stripe | Field | Required | Description | | ------------------------------------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Secret key | Yes | Secret key from the Stripe Dashboard (`sk_test_...` or `sk_live_...`) | | Send the statement descriptor suffix instead of the full descriptor | No | Turn on if Stripe rejects payments that set a full statement descriptor. See [Statement descriptors](https://docs.odus.com/docs/guides/integrations/gateways/stripe/.md#statement-descriptors) | ### Adyen | Field | Required | Description | | ---------------- | --------- | ------------------------------------------------------------------------- | | Merchant account | Yes | Your Adyen merchant account name | | API key | Yes | API key from the Adyen Dashboard | | Live URL prefix | Live only | The prefix from **Developers > API URLs > Prefix** in the Adyen Dashboard | ### Checkout.com | Field | Required | Description | | ------------------ | -------- | ---------------------------------------------------------------------- | | Secret key | Yes | Checkout.com secret key (`sk_sbox_...` for sandbox, `sk_...` for live) | | Processing channel | Yes | Processing channel ID (`pc_...`) | | Merchant city | No | Your city, used for payment statement descriptors | ### PayPal | Field | Required | Description | | ------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | Integration mode | Yes | `Vault` uses PayPal Vaulting. `Billing Agreement` uses the Billing Agreements API. Cannot be changed after creation | | Client ID | Yes | Client ID from your PayPal application | | Secret key | Yes | Secret key from your PayPal application | | Collect shipping address | No | Whether to collect the customer's shipping address. Available only for Vault mode | | Accept card payments | No | Whether the profile also processes card payments. See [Card payments](https://docs.odus.com/docs/guides/integrations/gateways/paypal/.md#card-payments) | warning Billing Agreements require Reference Transactions to be enabled on your PayPal account. Contact PayPal support to enable this feature before selecting this integration mode. ## Set up webhooks Webhooks allow your gateway to notify ODUS about payment status updates in real time. Each profile has a unique webhook URL generated by ODUS. You must register this URL in your gateway's dashboard and provide a webhook verification key so ODUS can validate incoming requests. To configure webhooks on a profile: 1. Go to **Cascades > Gateways** and click the profile. 2. Copy the **Webhook URL** shown on the edit page and register it in your gateway's dashboard. 3. Enter the **Webhook Verification Key**: * Stripe: the webhook secret from **Developers > Webhooks** in the Stripe Dashboard. * Adyen: the HMAC key from **Developers > Webhooks > Edit** in the Adyen Dashboard. * Checkout.com: the webhook secret from your Checkout.com Dashboard. * PayPal: the Webhook ID from your PayPal application. 4. Click **Update**. ## Edit a gateway profile 1. Go to **Cascades > Gateways** and click the profile you want to edit. 2. Update the **Title**, **Environment**, credential fields, or **Webhook Verification Key** as needed. 3. Click **Update**. note The **Integration** type (for example, Stripe or Adyen) cannot be changed after a profile is created. Create a new profile to use a different gateway. For PayPal, the **Integration mode** also cannot be changed after creation. You can also update a profile via the API: [`POST /v2/gateway-profiles/{id}`](https://docs.odus.com/docs/api/core/v2/gateway-profile-controller-update). ## Archive and reactivate a profile Archiving a profile makes it unavailable for new payments. Existing payments that have already been processed are not affected. To archive a profile: 1. Go to **Cascades > Gateways** and click the profile. 2. Click **Actions** and select **Archive**. 3. Confirm in the dialog. To reactivate an archived profile, follow the same steps and select **Reactivate** instead. ## View profile status The **Gateways** list shows all profiles for your account. The list indicates whether each profile is active or archived. To view the full details of a single profile via the API, use [`GET /v2/gateway-profiles/{id}`](https://docs.odus.com/docs/api/core/v2/gateway-profile-controller-find-one). --- # Merchant Configuration The Merchant Configuration page lets you set account-wide defaults that apply to all payments. Individual payment requests can override these defaults, but the values you configure here are used when no override is provided. To open this page, go to **Settings > General** in the ODUS Dashboard. You can also read and update the same settings via the API — see [`GET /v2/merchant-config`](https://docs.odus.com/docs/api/core/v2/merchant-config-controller-get) and [`POST /v2/merchant-config`](https://docs.odus.com/docs/api/core/v2/merchant-config-controller-set). Your account has exactly one configuration, so neither route takes an ID. An update changes only the fields you send. ## Dashboard settings These settings control how data is displayed in the ODUS Dashboard. They do not affect payment processing. ### Display currency The currency used when showing aggregate amounts in reports and statistics. Select any supported currency from the dropdown. This setting does not convert or affect the currencies charged to your customers. ### Timezone The timezone used for all reporting, scheduling, and subscription billing windows. Timezones are listed in IANA format (for example, `Europe/Paris` or `America/New_York`). If not set, ODUS defaults to UTC. important Changing the timezone affects when subscription renewals are grouped and when delayed captures trigger. Review your active subscriptions before changing this setting. ### Subscription grouping window In minutes. How far apart two of a customer's subscriptions may be created and still be shown together in the **Related subscriptions** section of a subscription's detail page. Enter a whole number between `0` and `1440` (24 hours). Subscriptions that share a **setup payment** — everything bought in one checkout, free items included — are always grouped, whatever this is set to. The window is what additionally catches an upsell, which is a second checkout and therefore a second payment. | Value | Behaviour | | ---------- | ---------------------------------------------------------------------------------------------------- | | `0` | The time window is off. Only subscriptions sharing a setup payment are grouped. This is the default. | | `1`–`1440` | Subscriptions created within this many minutes of each other are grouped as well. | Grouping is worked out when the page is opened, so changing this value re-groups your existing subscriptions immediately. It is a read-only view — nothing is ever cancelled automatically. See [Related Subscriptions](https://docs.odus.com/docs/guides/subscriptions/related-subscriptions.md) for how to read the section and open a free add-on from it. ## Default payment settings These settings define the default capture behaviour for all payment methods. You can override them per payment method type using the sections described below. ### Capture method Controls when funds are captured after a payment is authorized. | Value | Behaviour | | ------------------ | ------------------------------------------------------------------------------------ | | `automatic` | Funds are captured immediately at authorization. | | `manual` | Funds are not captured automatically. You must call the capture endpoint explicitly. | | `automaticDelayed` | Funds are captured automatically after the number of days set in **Capture delay**. | ### Capture delay Visible only when **Capture method** is set to `automaticDelayed`. Enter the number of days after authorization before the automatic capture triggers. The valid range is **1–5 days**. ### Recurring capture method The capture method applied to subscription renewal charges. By default it inherits the value from **Capture method**. You can override it independently if recurring charges require different behaviour. ### Recurring capture delay Visible only when **Recurring capture method** is `automaticDelayed`. The delay in days before recurring charges are auto-captured. The valid range is **1–5 days**. ### Capture at night When enabled, a delayed capture is scheduled at a random moment between 02:00 and 04:00 in the shopper's local time instead of at the exact moment the capture delay elapses. Captures are only ever moved later, never earlier, so the configured **Capture delay** is always respected. Two conditions apply: * Only **delayed** captures are affected. Immediate and manual captures are unchanged. * The shopper's UTC offset must be known. It is read from the shopper's browser at authorization, which happens automatically for payments made through the Checkout SDK, and remembered for that shopper afterwards. Authorizations that carry no browser information of their own — server-to-server charges, subscription renewals, and retries — reuse the offset last recorded for the same shopper, so they are scheduled at night as well. Payments for a shopper whose offset has never been recorded keep the standard schedule. Offsets are only recorded while **Capture at night** is enabled, so after turning it on a returning shopper's payments keep the standard schedule until their next authorization from a browser. note Shopper location is known only as the UTC offset reported at authorization, not as a full time zone. If a daylight-saving transition falls between authorization and capture, the effective window can shift by an hour. See [Captures](https://docs.odus.com/docs/guides/payments/capture.md#capturing-at-night) for how this fits into the delayed capture flow. ### Recurring payment bundling When enabled, same-day subscription renewal charges for the same customer are grouped into a single payment instead of separate charges. This reduces transaction fees and simplifies billing statements. See [Payment Bundling](https://docs.odus.com/docs/guides/subscriptions/payment-bundling.md) for bundling criteria, failure behaviour, and examples. ### Blacklist customer on chargeback When enabled, every chargeback you record adds the customer's email address to the blacklist, whether it is recorded from the Dashboard or through the API, so a charged-back customer is blocked without anyone having to remember to do it. Blacklisting also cancels every active subscription of every customer with that email immediately. See [The Blacklist](https://docs.odus.com/docs/guides/customers/customer-restrictions.md#the-blacklist). This setting is on by default. In the API it is the `blacklistCustomerOnChargeback` field. Turn it off if you would rather decide for each chargeback. Turning it off does not remove anyone already blacklisted; remove an entry from the [**Blacklist** page](https://docs.odus.com/docs/guides/customers/customer-restrictions.md#the-blacklist). See [Chargebacks](https://docs.odus.com/docs/guides/payments/chargebacks.md#blacklist-the-customer) for how this interacts with a chargeback request. ### Line item description The text that describes each purchased item to the shopper. It is sent to the payment provider as the item name — so it appears on PayPal order sheets and wallet screens — and it is also used for the payment note and the item summary in the Dashboard. | Value | Behaviour | | -------------- | --------------------------------------------------------------- | | `Price name` | The name of the price the item references. This is the default. | | `Product name` | The name of the product the price belongs to. | Pick **Product name** when your product names are the ones shoppers recognise and your price names are internal labels such as `Monthly USD Tier 2`. Names longer than 127 characters are shortened to fit. A `description` sent with a line item on [`POST /v2/payments`](https://docs.odus.com/docs/api/core/v2/payment-controller-create) always wins over this setting, so you can label individual items without changing your account default. note The description is stored on the line item when the payment is created, so changing this setting only affects payments made from then on. Existing payments — and subscription renewals, which reuse the cart of the payment that created them — keep the description they were created with. ## Card payment settings Tick **Set Card payment settings** to expand card-specific overrides. ### Processing mode | Value | Behaviour | | --------- | ------------------------------------------------------------------------------------------------------------------------ | | `cascade` | ODUS routes the payment through the selected cascade rule. | | `direct` | ODUS sends the payment directly to the selected gateway profile. | | `route` | The selected [route](https://docs.odus.com/docs/guides/routing/how-routes-work.md) chooses the gateway for each payment. | ### Default cascade Visible when mode is `cascade`. Select the cascade rule to apply to card payments by default. ### Default gateway profile Visible when mode is `direct`. Select the gateway profile to use for card payments by default. ### Default route Visible when mode is `route`. Select the route to apply to card payments by default. The list offers only routes that are active and have an active version, because a route without both cannot be saved as your default. To create and publish a route, see [Routing card payments with a route](https://docs.odus.com/docs/guides/routing/routing-card-payments.md). ### Card capture method and delay Per-payment-method override for the capture settings described in [Default payment settings](#default-payment-settings). If set to **Use default**, the root default applies. Capture delay for cards is **1–5 days**. ### Card recurring capture method and delay Same as above but for subscription renewal charges on cards. ## PayPal payment settings Tick **Set PayPal payment settings** to expand PayPal-specific overrides. PayPal always uses `direct` mode. You must select a gateway profile that supports PayPal. ### PayPal gateway The gateway profile used for all PayPal payments. Only profiles that support PayPal are shown. ### PayPal capture method and delay Same options as the default capture settings. Capture delay for PayPal is **1–15 days**. ### PayPal recurring capture method and delay Capture settings applied to PayPal subscription renewal charges. ## Apple Pay payment settings Tick **Set Apple Pay payment settings** to expand Apple Pay-specific overrides. Apple Pay always uses `direct` mode. You must select a gateway profile that supports Apple Pay. ### Apple Pay gateway The gateway profile used for all Apple Pay payments. Only profiles that support Apple Pay are shown. ### Apple Pay capture method and delay Same options as the default capture settings. Capture delay for Apple Pay is **1–5 days**. ### Apple Pay recurring capture method and delay Capture settings applied to Apple Pay subscription renewal charges. ## Saving changes Click **Update** to save all changes. The button is disabled until all required fields are valid. --- # Request Logs Request Logs are a record of every inbound API request made to ODUS. Use them to debug integration issues, audit API usage, and inspect the exact payloads that were sent and received. ## Accessing Request Logs In the ODUS Dashboard, navigate to **Developers > Request Logs**. The page shows a paginated list of requests. Each row displays: | Column | Description | | -------- | ------------------------------------ | | Status | HTTP response status code | | Method | HTTP method (`POST`, `DELETE`, etc.) | | Endpoint | The API path that was called | | Date | Timestamp of the request | ## Filtering logs Use the filter bar at the top of the list to narrow results. | Filter | Type | Description | | ------------ | ------------ | ------------------------------------------------------- | | Method | Select | Filter by HTTP method (`POST` or `DELETE`) | | Status Code | Number | Filter by exact HTTP response status code | | API endpoint | Text | Filter by a partial or full path (e.g. `/v2/payments`) | | Request ID | Text | Filter by the unique request ID | | Initiator | Multi-select | Filter by the key type used to authenticate the request | The **Initiator** filter accepts the following values: * `publishableKey` — requests made with a publishable key * `secretKey` — requests made with a secret key * `checkoutKey` — requests made during a checkout session * `sessionKey` — requests made with a session key ## Viewing a log entry Click any row in the list to open its detail panel on the right side of the page. The detail panel is divided into three sections. ### Request basic info | Field | Description | | ------------ | -------------------------------------- | | Request ID | The unique identifier for this request | | Timestamp | When the request was received | | API endpoint | The full path that was called | ### Request overview | Field | Description | | ----------- | --------------------------------------------- | | Method | HTTP method, shown as a colored label | | Status code | HTTP response code, shown as a colored label | | Client IP | IP address of the caller | | Initiator | Key type used to authenticate the request | | User agent | The `User-Agent` header sent with the request | ### Request & response details The bottom section shows the full **Request Body** and **Response Body** as formatted JSON. This is useful for inspecting exactly what your integration sent and what ODUS returned. ## API reference You can also retrieve request logs programmatically. * [`GET /v2/logs`](https://docs.odus.com/docs/api/core/v2/request-log-controller-find-all-paginated) — retrieve a paginated list of request logs. Supports filtering by `method`, `statusCode`, `url`, `requestId`, `initiator`, `clientIp`, `createdAt`, and more. * [`GET /v2/logs/{id}`](https://docs.odus.com/docs/api/core/v2/request-log-controller-find-one) — retrieve a single request log by its ID. In v2, `initiator` on a log entry is an open string rather than a fixed set, so new values can appear. Handle a value you do not recognise instead of failing on it. In v2, each log entry also carries `credentialId`, which tells you which credential made the request. For a key, it is the key's `id`, so you can match it against your list of keys. For a request made from the Dashboard, it is the ID of the signed-in user or of their session. `initiator` gives only the credential's type. `credentialId` is `null` when no credential can be identified. That covers requests made without one, and entries recorded before the field was introduced — so do not treat a `null` on an older entry as an unauthenticated request. ### Refused credentials A request refused because of its credential is logged with status `401`, including a read such as `GET /v2/payments`. That happens when the credential was deleted, deactivated or has expired, or when your account does not have access to the API. In v2, these entries carry `rejectionReason`: * `invalid_credential` — the key or session was not accepted. Check `credentialId` to find which one, and replace it in your integration. * `account_not_entitled` — the credential is valid, but your account does not currently have access to the API. Contact ODUS support. `rejectionReason` is `null` on every other entry. A request refused for a credential that does not belong to your account, or for having no credential at all, does not appear in your logs. The entry for a refused request may have no request or response body. In a log entry, the `Authorization` header, the key that authenticated the request and any key the request created appear as `[REDACTED]`. Reading request logs is recorded too. Each call to `GET /v2/logs` or `GET /v2/logs/{id}` — including the ones the Dashboard makes when you open **Request Logs** — adds a log entry with the `GET` method, the URL that was read (with its filters) and the `credentialId` that read it. These entries have no response body. To list them through the API, filter by `method` with the value `GET`. tip The API supports range filters on `createdAt` (e.g. `createdAt[gte]` and `createdAt[lte]`), which lets you query logs for a specific time window — useful for automated audits or incident investigation. --- # Manage Team Members The ODUS Dashboard lets you create user accounts for your team members and assign each one a role that controls what they can do. This guide explains how to add, edit, and remove users in the Dashboard. important Only users with the **Admin** role can create, update, or delete other users. Users are managed in the Dashboard only Core API v2 has no user endpoints. If you manage users through the v1 API today, move that work to the Dashboard before v1 stops being served on 4 November 2026. ## Available roles Every user must have exactly one role. | Role | Value | What the user can do | | ----------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------- | | **Admin** | `admin` | Full access to all Dashboard features | | **Developer** | `developer` | Full access except user management (cannot create, edit, or delete users) | | **Support Agent** | `supportAgent` | Read-only access to payments, customers, and subscriptions. Cannot access API keys, gateway profiles, webhooks, or user management | ## View team members In the Dashboard, navigate to **Admin > Users**. The page lists all users in your account. Click any row to open that user's details. ## Add a new team member 1. Go to **Admin > Users**. 2. Click the **Add** button in the top-right corner of the table. 3. Fill in the form: * **Email** (required) * **Password** (required) — must be at least 8 characters and include an uppercase letter, a lowercase letter, a number, and a special character * **Role** (required) — select `Admin`, `Developer`, or `Support Agent` * **First name**, **Last name**, **Username** — optional 4. Click **Add** to save. ## Edit a user 1. Go to **Admin > Users**. 2. Click the row for the user you want to edit. 3. Update the fields you want to change, then save. ## Remove a user 1. Go to **Admin > Users**. 2. Open the user you want to remove. 3. Click **Delete** and confirm. warning Deleting a user is permanent. Their active sessions are also deleted and they will be signed out immediately. ## Your own profile Each user can view and update their own account without needing to be an Admin: name, email, username, and password. Click your avatar or name in the top navigation bar to open your profile settings. --- # Webhook Subscriber Webhook subscribers represent a destination URL where ODUS will send webhook events for the events you subscribe to. You can create multiple webhook subscribers to send events to different URLs based on your application's needs. ## Creating a Webhook Subscriber When creating a new webhook subscriber, you must provide the following information: * `Recipient URL`: the URL where the webhook events will be sent. This should be an endpoint on your server that is **publicly accessible** and can handle incoming POST requests. * `Subscribed Events`: a list of events that you want to subscribe to. You can select multiple events, and you can change this list later if needed. * `Payload version`: the shape of the event body the subscriber receives, **Version 1** or **Version 2**. Version 1 is preselected. You cannot change the version after the subscriber is created. The [Webhooks guide](https://docs.odus.com/docs/guides/api-features/webhooks.md#payload-versions) explains the difference. Only the Payments API has this field. tip For local development, you can use tools like [ngrok](https://ngrok.com/) to expose your local server to the internet and provide a public URL for the webhook subscriber. After the webhook subscriber is created, you will be provided with a **Shared Secret**. This key is used to verify the authenticity of the webhook events sent to your endpoint. You should store this key securely and use it to validate incoming requests, it is only shown once during the creation of the webhook subscriber. ## Changing the payload version The **Payload Version** column of the Payments API list shows the version of each subscriber. To move to another version, add a second subscriber on the new version. Confirm that your endpoint processes its deliveries. The new subscriber receives only events from after its creation, and archiving the old one stops anything still on its way to it. So wait at least an hour after creating the new subscriber, and until none of the old subscriber's failed deliveries is still being retried. Then select **Archive** on the old one. Archiving deactivates a subscriber; it is not deleted. You can have at most five active subscribers, so you may need to archive one you no longer use first. The [Webhooks guide](https://docs.odus.com/docs/guides/upgrading/webhook-payload-v2.md#move-a-subscriber-to-version-2) has the full procedure. ## Choosing the API If your account has the Order Management addon enabled, the **Webhooks** page shows a tab per API: **Payments API** and **Orders API**. Pick the tab before adding a subscriber — the events you can subscribe to differ between the two. A subscriber always belongs to a single API. If you consume both, you will receive two feeds, each signed with its own shared secret, so make sure you verify a delivery with the secret of the API that sent it. The list shows the subscribers of the selected tab only, and a subscriber's detail page names the API it belongs to. More on handling webhooks can be found in the [Webhooks guide](https://docs.odus.com/docs/guides/api-features/webhooks.md). --- # Frequently Asked Questions Quick answers to the most common questions merchants ask about ODUS. ## 1. What does the `requires_action` status mean? A payment moves to `requires_action` when the customer must complete an additional step before processing can continue — most commonly a 3D Secure (3DS) authentication redirect. The payment object will include an `action.redirectUrl` that points the customer to the authentication page. Once the customer completes the action, the payment transitions to `succeeded`. If the action is not completed within 24 hours, the payment resets to `open` so a new attempt can be made. See [Payment Statuses](https://docs.odus.com/docs/guides/payments/payment-statuses.md) for the full state machine. ## 2. Why was my payment declined? Every failed payment includes an `outcome` object on the transaction with a standardized `errorCode`, a `declineType` (`soft_decline`, `hard_decline` or `not_attempted`), and a `safeErrorMessage` suitable for display to customers. Common reasons: | Code | Meaning | | ------------------------- | ---------------------------------------------------------------- | | `insufficient_funds` | The card does not have enough balance. | | `expired_card` | The card is past its expiry date. | | `invalid_card_details` | The card number, CVV, or other details are incorrect. | | `authentication_failed` | Authentication was required but could not be completed. | | `fraud_suspected` | The processor flagged the transaction as potentially fraudulent. | | `generic_processor_error` | A transient processor error — retrying may succeed. | Hard declines (`fraud_suspected`, `card_restricted`, `generic_hard_decline`, `authentication_rejected`) will not succeed on retry with the same card. Soft declines may succeed if retried or if the customer updates their payment method. A third type, `not_attempted`, means this payment was never authorized against the card, so the issuer refused nothing — read `errorCode` for the cause and the remedy. See [Error Codes](https://docs.odus.com/docs/guides/payments/error-codes.md) for the full reference. ## 3. How do I set up webhooks? 1. In the Dashboard, go to **Developers > Webhook Subscribers** and create a subscriber. 2. Save the generated shared secret — it is used to verify payload authenticity. 3. Expose a publicly accessible HTTPS endpoint that accepts POST requests. 4. Validate incoming requests by computing the HMAC-SHA256 of the request body using your shared secret and comparing it to the `X-Webhook-HMAC` header. 5. Respond with a `2xx` status code to acknowledge receipt. Webhooks are delivered at least once and retried up to 10 times over 3 days with exponential backoff. Use the `eventId` field to deduplicate. See [Webhooks](https://docs.odus.com/docs/guides/api-features/webhooks.md) for the full guide. ## 4. How do cascades work? A cascade defines which gateway profiles process your payments and in what order. When one gateway fails, ODUS automatically routes to the next gateway — no action required from you. Three routing algorithms are available: * **Round-robin** — distributes payments evenly across gateways. * **Weighted round-robin** — distributes by percentage weight (e.g., 70/30). * **Priority queue** — always tries the highest-priority gateway first, falling back on failure. Each gateway in a cascade can have optional transaction limits and caps per day, week, or month. See [How Cascade Routing Works](https://docs.odus.com/docs/guides/routing/cascade-overview.md) and [Configuring Cascades](https://docs.odus.com/docs/guides/routing/configuring-cascades.md). ## 5. How does the retry policy work for failed subscription payments? When a subscription renewal charge fails, ODUS automatically retries according to your retry policy. A policy contains ordered steps, each with: * **`retryDelay`** — days to wait before retrying. * **`useInitialGateway`** — whether to retry on the same gateway or target a different one. * **`priceReductionPercentage`** — optionally reduce the charge amount for that attempt. Your account has one retry policy, and it applies to every failed renewal. Read it with `GET /v2/retry-policy` and set it with `POST /v2/retry-policy`. Until you set one, a failed renewal is not retried. See [Configure a Payment Retry Policy](https://docs.odus.com/docs/guides/subscriptions/retry-policy.md). ## 6. How do I authenticate API requests? Include your secret key as a Bearer token in the `Authorization` header: ``` Authorization: Bearer skey_your_secret_key_here ``` Secret keys (`skey_` prefix) are for server-side use only. Create and manage them in the Dashboard under **Developers > Access Keys**. See [Authentication](https://docs.odus.com/docs/guides/api-features/authentication.md). ## 7. What is the difference between `status` and `displayStatus`? `status` is the core lifecycle field with four values: `open`, `requires_action`, `succeeded`, and `cancelled`. Use it in your integration logic. `displayStatus` is a derived, human-friendly label (e.g., `uncaptured`, `retrying`, `partially_reversed`) computed from `status` and the boolean lifecycle flags. It is useful for UI display but should not drive business logic — new values may be added in future releases. See [Payment Statuses](https://docs.odus.com/docs/guides/payments/payment-statuses.md). ## 8. How do I capture a payment manually? If your payment was created with `captureMethod: "manual"`, the payment will move to `succeeded` with `isCaptured: false` (displayStatus: `uncaptured`) after authorization. To capture: ``` POST /v2/payments/{id}/capture ``` You can capture the full authorized amount or a partial amount. Uncaptured authorizations expire according to the processor's hold period (typically 7 days). See [Capture](https://docs.odus.com/docs/guides/payments/capture.md). ## 9. How do I test payments without processing real charges? ODUS provides a test mode where you can simulate payments, declines, and 3DS flows without hitting live gateways. Use your test-mode API keys (available in the Dashboard) to make requests against the same API endpoints. See [Testing Resources](https://docs.odus.com/docs/guides/testing.md) for test card numbers and simulation scenarios. ## 10. How do I handle 3DS redirects in my checkout flow? When a payment returns `status: "requires_action"` with an `action.redirectUrl`: 1. Redirect the customer to the URL (or open it in an iframe/popup). 2. The customer completes authentication with their bank. 3. The bank redirects back to your configured return URL. 4. ODUS automatically finalizes the payment — poll or listen for the `payment.updated` webhook to confirm the outcome. If you use the ODUS Checkout SDK, 3DS handling is built in automatically. See [3DS and Redirect Handling](https://docs.odus.com/docs/guides/checkout/3ds-and-redirect-handling.md). --- # Integrations Explore the available integration options for connecting external services with ODUS. * [General](https://docs.odus.com/docs/guides/integrations/gateways/.md) — Connect payment gateways such as Stripe, Adyen, Checkout.com, and PayPal. --- # General Learn how to integrate various payment gateways (e.g., Stripe, PayPal) with ODUS to expand your payment processing options. ## Supported integrations * [Stripe](https://docs.odus.com/docs/guides/integrations/gateways/stripe/.md) * [Adyen](https://docs.odus.com/docs/guides/integrations/gateways/adyen/.md) * [Checkout.com](https://docs.odus.com/docs/guides/integrations/gateways/checkout/.md) * [PayPal](https://docs.odus.com/docs/guides/integrations/gateways/paypal/.md) ## PSP Webhooks Each gateway integration supports receiving webhook notifications from the payment provider. Webhooks allow ODUS to be notified about asynchronous events such as failed refunds or voids. Setting up webhooks is part of the gateway profile creation process in the ODUS Dashboard. After creating a new gateway profile, a **Webhook Setup** dialog is presented where you can: 1. Copy the ODUS webhook URL to configure in your payment provider's dashboard. 2. Enter the webhook verification credential (signing secret, HMAC key, or Webhook ID) obtained from the provider. For detailed instructions on setting up webhooks for each provider, see the provider-specific guides: * [Stripe Webhooks](https://docs.odus.com/docs/guides/integrations/gateways/stripe/stripe-webhooks.md) * [Adyen Webhooks](https://docs.odus.com/docs/guides/integrations/gateways/adyen/adyen-webhooks.md) * [Checkout.com Webhooks](https://docs.odus.com/docs/guides/integrations/gateways/checkout/checkout-webhooks.md) * [PayPal Webhooks](https://docs.odus.com/docs/guides/integrations/gateways/paypal/paypal-webhooks.md) ## Receiving notifications in your application Setting up PSP webhooks only allows ODUS to receive and process events from your payment provider. To be notified in **your own application** when these events are processed, you need to [create a webhook subscriber](https://docs.odus.com/docs/guides/dashboard/webhook-subscriber.md) in the ODUS Dashboard and subscribe to the `transaction.reverse_failed` event. This is currently the only event type emitted as a result of PSP webhook processing. It is triggered when a reversal operation (refund, void, or cancellation) fails on the payment provider's side. For more details on handling ODUS webhooks, including payload format and HMAC signature verification, see the [Webhooks guide](https://docs.odus.com/docs/guides/api-features/webhooks.md). --- # Adyen Adyen is a global payment company that allows businesses to accept payments in a single system. note The integration is using Adyen Checkout API v71 | Payment method | Recurring payments | Recurring retries | Delayed capture | Partial refunds | | -------------- | ------------------ | ----------------- | --------------- | --------------- | | Credit cards | Yes | Yes | Yes | Yes | | Apple Pay | Yes | Yes | Yes | Yes | ## Adyen account setup important You need to contact Adyen support to enable some of the roles, such as "API PCI Payments role". You may also be asked to submit ODUS PCI documentation, in which case please contact ODUS support. 1. Ensure you have a [merchant account](https://docs.adyen.com/account/manage-account-structure) 2. (Adyen Live only) Obtain your [Live URL Prefix](https://docs.adyen.com/development-resources/live-endpoints). 3. Set up the Additional Data by going to **Developers > Additional Data** and enabling: * Recurring details * Recurring contract information 4. Enable payment method tokenization by going to **Settings > Payments > Checkout** and enabling "Payout" under the "Tokenization" section. 5. Create an API credential with the following roles and save the API key: * API PCI Payments role * Checkout webservice role * Merchant PAL Webservice role * Merchant Recurring role * (Optional, for Apple Pay) API Clientside Encryption Payment role ## Gateway profile setup | Field | Required | Description | | ---------------- | --------------- | ----------------------------------------- | | Integration | Yes | Adyen | | Environment | Yes | Must match the type of your Adyen account | | Merchant Account | Yes | Your Adyen merchant account name | | API Key | Yes | API Key with the roles listed above | | Live URL Prefix | Yes (Live only) | Live URL Prefix provided by Adyen | ## Credential verification When you create or update the gateway profile, ODUS verifies the **API Key** and **Merchant Account** against Adyen before saving. If verification fails, the profile is not saved and an error is returned. ## Next steps * [Setting up Webhooks](https://docs.odus.com/docs/guides/integrations/gateways/adyen/adyen-webhooks.md) — Configure Adyen to send webhook notifications to ODUS for reversal failure detection. * [Setting up Apple Pay](https://docs.odus.com/docs/guides/integrations/gateways/adyen/adyen-apple-pay.md) — Accept Apple Pay payments via Adyen. --- # Setting up Apple Pay Learn how to configure ODUS to accept Apple Pay payments via Adyen. warning ODUS only supports Apple Pay integration with Adyen using Adyen-managed Apple Pay certificates. Self-managed certificates are **not supported** at this time. Before proceeding, ensure you have completed the general Adyen integration as described in the [Adyen integration guide](https://docs.odus.com/docs/guides/integrations/gateways/adyen/.md). Once you have a working Adyen integration, setting up Apple Pay involves changes in your Adyen account as well as minor configuration on your environment. ## Configuring your environment important Apple Pay **will not work** with `http://localhost`, you must have a valid domain with HTTPS enabled. You can use tunnel services like [ngrok](https://ngrok.com/) to expose your local development environment over HTTPS for testing purposes. There's no additional requirements from ODUS to support Apple Pay via Adyen, however Apple Pay imposes some requirements on your domain that you need to fulfill: * Your domain must be served over HTTPS with a valid SSL certificate. Apple Pay will not work on domains with self-signed certificates or when served over HTTP. * To accept real payments, you must host the Apple domain association file on your domain as described below. ### Hosting the domain association file note This section only applies if you're using Adyen Live environment. In Adyen Test, you don't need to host the domain association file. Apple Pay requires that each domain is verified by hosting a specific file on your server at `https:///.well-known/apple-developer-merchantid-domain-association`. Adyen provides that file for you to host in their [documentation](https://docs.adyen.com/payment-methods/apple-pay/api-only#going-live). Make sure that the file is: * Publicly accessible without any password or authentication * Served over HTTPS * Served with the correct `Content-Type` header: `text/plain` You can run the following command to validate that your domain is setup correctly (replace `` with your actual domain): ``` domain="your-domain.com"; [ "$(curl -s "https://${domain}/.well-known/apple-developer-merchantid-domain-association" | cksum)" = "$(curl -s "https://eu.adyen.link/.well-known/apple-developer-merchantid-domain-association" | cksum)" ] && echo "✓ Checksums match for ${domain}" || echo "✗ Checksums differ for ${domain}" ``` If you get "✓ Checksums match", your domain is correctly hosting the file. If you get "✗ Checksums differ", double-check that the file is correctly hosted and accessible. ## Adyen account setup important You may have more than one Apple Pay payment method in your Adyen account, especially if you were integrating Apple Pay previously. It is important that you only have **one active Apple Pay payment method** (named "Apple Pay", don't confuse with network-specific ones like "Apple Pay - MasterCard"). Failing to ensure that will result in intermittent failures to initialize Apple Pay inside ODUS Checkout. You need to request Apple Pay to be enabled in your Adyen account if it is not already, you can do this by going to **Settings > Payment methods > Apple Pay** and following the instructions there. Once Apple Pay is enabled, you need to perform a few more steps to complete the setup. ### Domain registration important Your Adyen user needs to have the `Change payment methods` role assigned to be able to register new domains. You can request this role from Adyen support if you do not have it. tip If you're using a tunnel service for local development, you can obtain a permanent domain for that service and register it in Adyen as well. To use Apple Pay, you must register all domains where you plan to show Apple Pay buttons inside ODUS Checkout. Keep in mind that this includes top-level domains (e.g., `odus.com`) and subdomains (e.g., `docs.odus.com`) including `www`, which is also considered a subdomain and must be registered separately if you plan to use it (e.g., `www.odus.com`). Navigate to **Settings > Payment methods** in Adyen Customer Area, choose **Apple Pay** from the list of payment methods (not **Apple Pay - MasterCard** or similar) and scroll down in the section to the right until you see the "Acquirer account" section. You should be able to edit your Apple Pay domains there: ![Adyen Apple Pay payment method domains](/assets/images/adyen-apple-pay-config-2fee2a6428e394075f33ba3c907d3087.png) ### API Credentials configuration note If you haven't created API credentials for ODUS yet, follow the instruction in the [Adyen integration guide](https://docs.odus.com/docs/guides/integrations/gateways/adyen/.md#adyen-account-setup) to create them. Navigate to **Developers > API Credentials** and edit your API credential that is used for ODUS integration. You need to ensure that Apple Pay is present in the "Wallet Payment methods" section and is configured to use Adyen-managed certificates. After that add the following permissions, if not already present: * `API Clientside Encryption Payment role` * `Management API — Payment methods read` ## Testing Apple Pay If integrating through Adyen Test environment, you have to use an Apple Sandbox account with a test card. Regular Apple ID with real cards will not work in Adyen Test environment. You need to have an Apple Developer account to create Sandbox testers, see [Apple's documentation](https://developer.apple.com/apple-pay/sandbox-testing/) for more details. To test in Adyen Live environment, you can use a regular Apple ID with real cards. Make sure that your domain is registered in Adyen Live and that the Apple domain association file is correctly hosted. In Live environment, your card will be charged for real money, so make sure to use a low-value transaction for testing. ## Troubleshooting ### Apple Pay does not appear on the checkout page Apple Pay button will only appear in Safari as it is the only browser that supports Apple Pay natively. If the button still doesn't appear in Safari, ensure that: * You have a default gateway configured for Apple Pay in **Settings > General** in ODUS Dashboard. * Apple Pay is enabled in [ODUS Checkout configuration](https://docs.odus.com/docs/guides/checkout/drop-in/appearance.md#additional-payment-methods). ### Apple Pay button appears, but the payment sheet closes immediately upon opening This means that ODUS was not able to obtain an Apple Pay merchant session from Adyen. This is usually caused by one of the following reasons: * Your domain is not [registered in Adyen](#domain-registration) * The [Apple domain association file](#hosting-the-domain-association-file) is missing or inaccessible * There is [more than one Apple Pay payment method](#adyen-account-setup) in Adyen or the active one is configured to use self-hosted Apple Pay certificates. ### Payments fail to complete when using Adyen Test environment Apple Pay transactions in Adyen Test environment require the use of Apple Sandbox test cards. Ensure that you are using a valid Apple Sandbox account and test card when testing Apple Pay in Adyen Test environment. Regular cards will not work in the test environment. --- # Setting up Webhooks Learn how to configure ODUS to receive webhook notifications from Adyen. ## Overview Webhooks allow Adyen to notify ODUS about events that happen asynchronously, such as failed refunds or cancellations. When properly configured, ODUS will automatically process these notifications and update the corresponding payment records. note ODUS currently processes the following Adyen webhook events, all related to reversal failure detection: | Adyen event code | Description | | ------------------ | --------------------------------------------------------------- | | `REFUND_FAILED` | A refund has explicitly failed | | `REFUND` | A refund notification (tracked if `success` is `false`) | | `REFUND_WITH_DATA` | A refund with additional data (tracked if `success` is `false`) | | `CANCELLATION` | A cancellation/void (tracked if `success` is `false`) | | `TECHNICAL_CANCEL` | A technical cancellation (tracked if `success` is `false`) | | `CANCEL_OR_REFUND` | A cancel-or-refund action (tracked if `success` is `false`) | ## Prerequisites Before setting up webhooks, ensure you have completed the general [Adyen integration](https://docs.odus.com/docs/guides/integrations/gateways/adyen/.md) and have a working gateway profile in ODUS. Your Adyen user needs the **Merchant admin** or **Merchant technical integrator** role to create and manage webhooks. ## Step 1 — Get the webhook URL from ODUS When you create a new gateway profile in the ODUS Dashboard, a **Webhook Setup** dialog appears automatically after the profile is created. This dialog provides: 1. A **Webhook URL** — a read-only field with a copy button. This URL points to the ODUS webhook endpoint and includes your merchant and gateway profile identifiers as query parameters. Copy this URL; you will need it in the next step. 2. A **Webhook Verification Key** field — you will fill this in after configuring the webhook in Adyen (Step 3). tip If you skipped the webhook setup during gateway profile creation, you can still configure it later by editing the gateway profile in the ODUS Dashboard. The webhook URL and verification key fields are available in the edit form. ## Step 2 — Create a webhook in Adyen 1. Log in to the [Adyen Customer Area](https://ca-test.adyen.com/) (test) or [ca-live.adyen.com](https://ca-live.adyen.com/) (live). 2. Navigate to **Developers > Webhooks**. 3. Click **+ Create new webhook**. 4. Select **Standard** from the list and click **Add**. 5. Under **General**: * Toggle **Enabled** on. * Select the appropriate **Merchant accounts** (the merchant account used for your ODUS integration). 6. Under **Server configuration**: * Paste the **Webhook URL** you copied from the ODUS Dashboard into the **URL** field. * Set the **Method** to **JSON**. ODUS reads only JSON. It rejects every webhook sent in the **HTTP POST** or **SOAP** format, so none of them updates your payments. * Set the **Encryption protocol** to **TLSv1.2** or **TLSv1.3**. 7. Under **Events**, subscribe to the following event types: * `REFUND_FAILED` * `REFUND` * `REFUND_WITH_DATA` * `CANCELLATION` * `TECHNICAL_CANCEL` * `CANCEL_OR_REFUND` 8. Under **Security**, click **Generate** to create a new HMAC key. warning The HMAC key is shown **only once** after generation. Copy it immediately before proceeding. 9. Click **Save configuration**. ## Step 3 — Copy the HMAC key to ODUS 1. After generating the HMAC key in the previous step, copy it. 2. Go back to the ODUS Dashboard — either in the Webhook Setup dialog (if still open) or by editing the gateway profile. 3. Paste the HMAC key into the **Webhook Verification Key** field. 4. Click **Save**. ## Important notes * If Adyen reports that webhooks to ODUS are failing, check that the webhook's **Method** is **JSON** and that the **Webhook Verification Key** in your ODUS gateway profile matches the HMAC key in Adyen. * Adyen does **not** copy webhook configurations from test to live. You must create separate webhook configurations for each environment. * Adyen expects a `2xx` response within **10 seconds**. If the response is delayed or missing, the event is queued for retry. * In **live** environments, Adyen requires HTTPS on ports **443**, **8443**, or **8843**. In test environments, HTTP is also accepted on ports 80, 8080, or 8888. * After generating a new HMAC key, Adyen continues to accept events signed with the old key for a short propagation period. * If you need to allowlist Adyen's IP addresses for firewall rules, use the domain `out.adyen.com` (Adyen does not provide a static IP list). --- # Checkout.com Checkout.com is a cloud-based payment platform that enables businesses to accept payments globally through a unified API. | Payment method | Recurring payments | Recurring retries | Delayed capture | Partial refunds | | -------------- | ------------------ | ----------------- | --------------- | --------------- | | Credit cards | Yes | Yes | Yes | Yes | ## Checkout.com account setup 1. Create a [Checkout.com account](https://www.checkout.com/) if you don't already have one. 2. Obtain your API keys from the [Checkout.com Dashboard](https://dashboard.checkout.com/) under **Developers > Keys**. 3. Copy the **Secret key**. 4. Note your **Processing Channel ID** — you can find it under **Settings > Channels**. ## Gateway profile setup | Field | Required | Description | | ------------------ | -------- | ----------------------------------------------------- | | Integration | Yes | Checkout.com | | Environment | Yes | Must match the mode of your Checkout.com account | | Secret Key | Yes | Secret key from the Checkout.com Dashboard | | Processing Channel | Yes | Processing Channel ID from the Checkout.com Dashboard | ## Credential verification When you create or update the gateway profile, ODUS verifies the **Secret Key** against Checkout.com before saving. If verification fails, the profile is not saved and an error is returned. --- # Setting up Webhooks Learn how to configure ODUS to receive webhook notifications from Checkout.com. ## Overview Webhooks allow Checkout.com to notify ODUS about events that happen asynchronously, such as declined captures, refunds or voids. When properly configured, ODUS will automatically process these notifications and update the corresponding payment records. note ODUS currently processes the following Checkout.com webhook events: | Checkout.com event | Description | | -------------------------- | ------------------------------------------------------- | | `payment_capture_declined` | A capture has been declined | | `payment_refund_declined` | A refund has been declined | | `payment_void_declined` | A void has been declined | | `payment_refunded` | A refund notification (tracked if the operation failed) | | `payment_voided` | A void notification (tracked if the operation failed) | ## Declined captures Checkout.com accepts a capture request first and approves or declines it afterwards. ODUS records the capture as soon as Checkout.com accepts it, so a capture that Checkout.com declines later is only corrected when the `payment_capture_declined` event arrives. When it arrives, ODUS: * marks the transaction's capture as failed, and sends you [`transaction.capture_failed`](https://docs.odus.com/docs/api/core/v2/webhook-transaction-capture-failed); * leaves the payment authorized and uncaptured, so you can capture it again or cancel it; * stops capturing that payment automatically, including a payment with a delayed automatic capture. warning If your webhook configuration does not include `payment_capture_declined`, a declined capture stays captured in ODUS and no error is reported. Funds for that payment never arrive. ## Declined refunds and voids Checkout.com can also decline a refund or a void after accepting it. ODUS records the refund or void as soon as Checkout.com accepts it, so one that Checkout.com declines later is only corrected when the `payment_refund_declined` or `payment_void_declined` event arrives. When it arrives, ODUS: * takes the declined refund or void off the payment's reversed amount, so the payment shows what Checkout.com actually did. A declined partial refund leaves any other refunds of the payment in place; * marks the transaction's refund or void as failed, and sends you [`transaction.reverse_failed`](https://docs.odus.com/docs/api/core/v2/webhook-transaction-reverse-failed). You receive it once per transaction: if a second refund of the same transaction is declined, ODUS corrects the reversed amount without sending it again; * lets you refund or void the payment again. warning If your webhook configuration does not include `payment_refund_declined` and `payment_void_declined`, a declined refund or void stays recorded in ODUS and no error is reported. The shopper is not refunded, or the hold on their card is not released. ## Prerequisites Before setting up webhooks, ensure you have completed the general [Checkout.com integration](https://docs.odus.com/docs/guides/integrations/gateways/checkout/.md) and have a working gateway profile in ODUS. Your Checkout.com user needs the **Admin** or **Developer** role, or a custom role with the following permissions: * Create, edit and delete workflow notifications * View secret keys * View the list of workflow notifications ## Step 1 — Get the webhook URL from ODUS When you create a new gateway profile in the ODUS Dashboard, a **Webhook Setup** dialog appears automatically after the profile is created. This dialog provides: 1. A **Webhook URL** — a read-only field with a copy button. This URL points to the ODUS webhook endpoint and includes your merchant and gateway profile identifiers as query parameters. Copy this URL; you will need it in the next step. 2. A **Webhook Verification Key** field — you will fill this in after configuring the webhook in Checkout.com (Step 3). tip If you skipped the webhook setup during gateway profile creation, you can still configure it later by editing the gateway profile in the ODUS Dashboard. The webhook URL and verification key fields are available in the edit form. ## Step 2 — Create a webhook configuration in Checkout.com 1. Log in to the [Checkout.com Dashboard](https://dashboard.checkout.com/). 2. Click the **Developers** icon in the top navigation bar. 3. Open the **Webhooks** tab. 4. Click **Create configuration**. 5. Enter a **Name** for the webhook (e.g., "ODUS"). 6. Paste the **Webhook URL** you copied from the ODUS Dashboard into the **Endpoint URL** field. 7. Next to the **Signature key** field, click **Generate key** to create a new signature key and copy it immediately. 8. Select the following **event types**: * `payment_capture_declined` * `payment_refund_declined` * `payment_void_declined` * `payment_refunded` * `payment_voided` 9. Select the appropriate **entities or processing channels** to receive webhooks for. 10. Click **Create webhook**. ## Step 3 — Copy the signature key to ODUS 1. After generating the signature key in the previous step, copy it. 2. Go back to the ODUS Dashboard — either in the Webhook Setup dialog (if still open) or by editing the gateway profile. 3. Paste the signature key into the **Webhook Verification Key** field. 4. Click **Save**. ## Important notes * Checkout.com responds to webhook delivery failures with automatic retries — up to **8 times** at increasing intervals (5 min, 10 min, 15 min, 30 min, 1 hour, 4 hours, 12 hours, 12 hours). * Webhook endpoints must respond within **10 seconds**, otherwise the delivery is considered failed and will be retried. * Webhook delivery order is **not guaranteed**. * You can suspend, restore, or delete webhook configurations from the Checkout.com Dashboard at any time. --- # PayPal PayPal is a widely used digital payments platform that allows businesses to accept PayPal payments, including PayPal Checkout and Pay Later options. A PayPal gateway profile can also process card payments; see [Card payments](#card-payments). | Payment method | Recurring payments | Recurring retries | Delayed capture | Partial refunds | | -------------- | ------------------ | ----------------- | --------------- | --------------- | | PayPal | Yes | Yes | Yes | Yes | | Card | Yes | Yes | Yes | Yes | Recurring payments charge the PayPal account the customer approved during an earlier checkout, in both [integration modes](#integration-modes). A merchant-initiated payment for a customer with no approved PayPal account is declined, because PayPal requires the customer to approve the first payment. Recurring card payments charge the saved card, linked to an earlier approved payment on it; see [Merchant-initiated card payments](#merchant-initiated-card-payments). Retries always use the gateway profile of the first attempt; see [Configure a Payment Retry Policy](https://docs.odus.com/docs/guides/subscriptions/retry-policy.md). Capture can be delayed by up to 15 days; see [PayPal payment settings](https://docs.odus.com/docs/guides/dashboard/merchant-configuration.md#paypal-payment-settings). ## PayPal account setup 1. Create a [PayPal Developer account](https://developer.paypal.com/) if you don't already have one. 2. Navigate to **Apps & Credentials** and create a new REST API app (or use an existing one). 3. Copy the **Client ID** and **Secret Key** from your app's credentials. ## Gateway profile setup | Field | Required | Description | | ------------------------ | -------- | ------------------------------------------------------------------------------------ | | Integration | Yes | PayPal | | Environment | Yes | Must match the mode of your PayPal app credentials | | Client ID | Yes | Client ID from the PayPal Developer Dashboard | | Secret Key | Yes | Secret key from the PayPal Developer Dashboard | | Integration Mode | Yes | `vault` or `billingAgreement` — cannot be changed after creation | | Webhook ID | No | Webhook ID from the PayPal Developer Dashboard, used for signature verification | | Collect Shipping Address | No | Whether to collect the shipping address from PayPal. Only available for `vault` mode | | Card Processing | No | Whether this profile also accepts card payments. See [Card payments](#card-payments) | ## Statement descriptors PayPal adds the payment's statement descriptor suffix after `PAYPAL *` and the descriptor in your PayPal account, then cuts the line at 22 characters. Give your products a short statement descriptor suffix so it is not cut off. A payment without a suffix sends its full statement descriptor instead. See [Statement descriptors](https://docs.odus.com/docs/guides/products/overview.md#statement-descriptors). ## Credential verification When you create or update the gateway profile, ODUS verifies the **Client ID** and **Secret Key** against PayPal before saving. If verification fails, the profile is not saved and an error is returned. ## Integration modes PayPal supports two integration modes, chosen at gateway profile creation. The integration mode **cannot be changed** after the profile is created. ### Vault Uses PayPal's Vault API to tokenize payment sources. The purchase amount is displayed on the PayPal checkout screen. This mode supports the **Collect Shipping Address** option, which retrieves the customer's shipping address from their PayPal account during checkout. ### Billing Agreement Uses PayPal's Billing Agreement API to establish a consent-based payment agreement with the customer. Unlike Vault, the purchase amount is **not** displayed on the PayPal checkout screen, and collecting shipping addresses is **not** supported. warning Reference Transactions must be enabled on your PayPal account to use the Billing Agreement flow. Contact PayPal support to enable this feature. ## Card payments A PayPal gateway profile can process card payments as well as PayPal payments. The customer enters their card in your checkout, and PayPal authorizes or captures it in the same request. A card that needs 3-D Secure takes one extra step, described below. 1. Ask PayPal to enable card processing on your PayPal account. Without it, PayPal declines every card payment. 2. Turn on card processing for the profile. Do one of these: * In the Dashboard, select **Accept card payments** on the profile; see [Gateway profiles](https://docs.odus.com/docs/guides/dashboard/gateway-profiles.md#paypal). * With API v2, send `configuration.cardProcessing: true` to `POST /v2/gateway-profiles`, or to `POST /v2/gateway-profiles/{id}` for an existing profile. API v1 does not accept the setting. 3. Point your card payment options at the profile, as you would for any card gateway. ODUS sends the customer's billing address with the card when `billingAddress.country` is a two-letter ISO 3166-1 country code. Issuers use it to check the card, so a missing address can increase declines. Turning card processing off removes card from the profile's supported payment methods. Card payment options that still point at the profile are declined with `merchant_configuration_error` until you point them at another gateway. ### 3-D Secure ODUS asks PayPal for 3-D Secure when the card requires it, for example under strong customer authentication rules for cards issued in the EEA or the UK. Cards that don't need it are paid in one request, as above. When a card needs it, the customer is redirected to their card issuer's challenge and then back to your Return URL, as with 3-D Secure on any other card gateway. Your checkout handles this as described in [3DS and redirect handling](https://docs.odus.com/docs/guides/checkout/3ds-and-redirect-handling.md). The payment is authorized or captured only after the customer returns. If the issuer does not authenticate the customer, the payment is declined with `authentication_failed`, `authentication_rejected` or `authentication_unavailable`; see [Error codes](https://docs.odus.com/docs/guides/payments/error-codes.md) for what each one means. A customer who returns without completing the challenge is declined with `authentication_failed`. ### Merchant-initiated card payments Subscription renewals, their retries and other merchant-initiated payments can charge a saved card through a PayPal profile. ODUS sends each one to PayPal as a payment from a stored card, linked to an earlier approved payment on the same card, and without the card's security code. No customer is present to take a challenge, so ODUS does not ask for 3-D Secure. A merchant-initiated card payment is declined with `payment_method_unusable`, and is not sent to PayPal, when ODUS has nothing to link it to: * the card has no earlier approved payment, on any gateway, that returned a card network reference, * the card's brand is unknown, or * the card is a Mastercard or Discover card, and ODUS does not have the date the card network approved the earlier payment. PayPal requires that date on these networks. ODUS records it only from payments taken through PayPal, so this applies to a card whose network reference came from a payment on another gateway. Take the first payment with the customer present, through a PayPal profile, so that later payments have one to link to. ## Shipping address on saved-account payments When ODUS charges a PayPal account the customer approved in an earlier checkout — a renewal, a retry or a merchant-initiated payment — it sends the customer's shipping address to PayPal with the payment, in both integration modes. PayPal Seller Protection for physical items covers a transaction only when you ship to the address on it, so ship these orders to the customer's shipping address. The address is the customer's `shippingAddress`; see [Managing customers](https://docs.odus.com/docs/guides/customers/managing-customers.md) to set or update it. A shipping address PayPal rejects does not decline the payment: ODUS charges the account without it. warning ODUS sends the address only when `shippingAddress.country` is a two-letter ISO 3166-1 country code, such as `US` or `GB`, or PayPal's own `C2`. A customer with no country, or with a value such as `USA` or `United Kingdom`, is charged without a shipping address, and no error is returned. The first payment is unchanged: the customer approves it on PayPal, and in Vault mode **Collect Shipping Address** retrieves the address the customer chooses there. --- # Setting up Webhooks Learn how to configure ODUS to receive webhook notifications from PayPal. ## Overview Webhooks allow PayPal to notify ODUS about events that happen asynchronously, such as a payment PayPal denies after holding it for review, or a refund that fails. When properly configured, ODUS will automatically process these notifications and update the corresponding payment records. note ODUS currently processes the following PayPal webhook events: | PayPal event | Description | | ------------------------------- | -------------------------------------------------------------------------------------------------- | | `PAYMENT.CAPTURE.DENIED` | PayPal denied a payment it held for review. The payment fails | | `PAYMENT.CAPTURE.DECLINED` | PayPal declined a payment it held for review. The payment fails | | `PAYMENT.CAPTURE.REFUNDED` | A capture has been refunded (tracked if the status indicates failure) | | `PAYMENT.CAPTURE.REVERSED` | A capture has been reversed (tracked if the status indicates failure) | | `PAYMENT.AUTHORIZATION.CREATED` | An authorization was created. The payment fails only if PayPal reports the authorization as denied | | `PAYMENT.AUTHORIZATION.VOIDED` | An authorization has been voided (tracked if the status indicates failure, or if PayPal denied it) | warning If your webhook listener was set up before `PAYMENT.CAPTURE.DENIED`, `PAYMENT.CAPTURE.DECLINED` and `PAYMENT.AUTHORIZATION.CREATED` were added, edit it in PayPal and select all three events. Until you do, a payment PayPal denies after review stays `succeeded` in ODUS. ## Payments PayPal holds for review PayPal can hold a payment for review before it settles. ODUS reports a held payment as successful straight away: you receive `payment.succeeded`, and `payment.captured` if it was captured. This covers a payment captured when it is made, and an authorization. A capture you make later, of an existing authorization, is not changed by these events. When PayPal completes the payment, nothing changes. When PayPal denies it, ODUS fails the payment: * The payment returns to `open`, and its display status becomes `failed`. * Its captured amount returns to 0, and the decline reason is recorded on the payment. * You receive `payment.attempt_failed` for the payment. If you fulfil orders on `payment.succeeded` or `payment.captured`, also handle `payment.attempt_failed` for the same payment, and stop or reverse the fulfilment. warning An invoice the payment had already paid stays `paid`, and a subscription the payment started stays active. Update them yourself when you receive `payment.attempt_failed` for a payment you had already fulfilled. ## Prerequisites Before setting up webhooks, ensure you have completed the general [PayPal integration](https://docs.odus.com/docs/guides/integrations/gateways/paypal/.md) and have a working gateway profile in ODUS. ## Step 1 — Get the webhook URL from ODUS When you create a new gateway profile in the ODUS Dashboard, a **Webhook Setup** dialog appears automatically after the profile is created. This dialog provides: 1. A **Webhook URL** — a read-only field with a copy button. This URL points to the ODUS webhook endpoint and includes your merchant and gateway profile identifiers as query parameters. Copy this URL; you will need it in the next step. 2. A **Webhook ID** field — you will fill this in after subscribing the webhook in PayPal (Step 3). important Unlike other payment providers, PayPal requires a **Webhook ID** instead of a webhook secret. The Webhook ID is not a secret value — it is a PayPal-assigned identifier for your webhook subscription that ODUS uses for certificate-based signature verification. tip If you skipped the webhook setup during gateway profile creation, you can still configure it later by editing the gateway profile in the ODUS Dashboard. The webhook URL and Webhook ID fields are available in the edit form. ## Step 2 — Subscribe a webhook listener in PayPal 1. Log in to the [PayPal Developer Dashboard](https://developer.paypal.com/dashboard/applications/). 2. Navigate to **Apps & Credentials**. 3. Select the **REST API app** that you are using for your ODUS integration. 4. Scroll down to the **Webhooks** section. 5. Click **Add Webhook** (or "Subscribe a listener URL"). 6. Paste the **Webhook URL** you copied from the ODUS Dashboard into the **Listener URL** field. 7. Select the following **event types**: * `PAYMENT.CAPTURE.DENIED` * `PAYMENT.CAPTURE.DECLINED` * `PAYMENT.CAPTURE.REFUNDED` * `PAYMENT.CAPTURE.REVERSED` * `PAYMENT.AUTHORIZATION.CREATED` * `PAYMENT.AUTHORIZATION.VOIDED` 8. Click **Save**. important PayPal requires the webhook listener URL to use **HTTPS on port 443**. Ensure that your ODUS API is accessible over HTTPS. ## Step 3 — Copy the Webhook ID to ODUS After subscribing the webhook listener: 1. In the **Webhooks** section of your PayPal app, locate the webhook you just created. PayPal assigns a **Webhook ID** to each listener upon creation. 2. Copy the Webhook ID. 3. Go back to the ODUS Dashboard — either in the Webhook Setup dialog (if still open) or by editing the gateway profile. 4. Paste the Webhook ID into the **Webhook Verification Key** field (labeled "Webhook ID" for PayPal integrations). 5. Click **Save**. ## Important notes * PayPal retries webhook deliveries for up to **3 days**, making up to **25 retry attempts** on non-`2xx` responses. * After 3 days of failed delivery, the event is marked as failed but can be resent manually from the [PayPal Webhook Events Dashboard](https://developer.paypal.com/dashboard/webhooksSimulator). * You can subscribe up to **10 webhook URLs** per PayPal REST API app. * Only events from the specific REST API app are delivered. If you have multiple apps, each requires its own webhook subscription. * PayPal uses certificate-based signature verification (not HMAC). The Webhook ID you provide to ODUS is used as part of this verification process. --- # Stripe Stripe is a global payments platform that provides APIs and tools for businesses to accept and manage online payments. | Payment method | Recurring payments | Recurring retries | Delayed capture | Partial refunds | | -------------- | ------------------ | ----------------- | --------------- | --------------- | | Credit cards | Yes | Yes | Yes | Yes | ## Stripe account setup 1. Create a [Stripe account](https://dashboard.stripe.com/register) if you don't already have one. 2. Obtain your API keys from the [Stripe Dashboard](https://dashboard.stripe.com/apikeys) under **Developers > API keys**. 3. Copy the **Secret key** (`sk_...`). ## Gateway profile setup | Field | Required | Description | | ------------------------------------------------------------------- | -------- | ------------------------------------------------------------------- | | Integration | Yes | Stripe | | Environment | Yes | Must match the mode of your Stripe API keys | | Secret Key | Yes | Secret key from the Stripe Dashboard (`sk_...`) | | Send the statement descriptor suffix instead of the full descriptor | No | Off by default. See [Statement descriptors](#statement-descriptors) | ## Statement descriptors By default, ODUS sends the payment's statement descriptor to Stripe, and it replaces your Stripe account's descriptor on the statement. Newer Stripe accounts accept only a suffix on card payments. With one of these accounts, Stripe rejects every card payment that sets a full statement descriptor. To fix this: 1. Set a shortened descriptor in your [Stripe business details](https://dashboard.stripe.com/settings/business-details). 2. Turn on **Send the statement descriptor suffix instead of the full descriptor** on the gateway profile (`useStatementDescriptorSuffix` in the API). 3. Give your products a statement descriptor suffix. With the option on, the statement reads `PREFIX* SUFFIX`, and the whole line must fit in 22 characters. A payment without a suffix uses your Stripe account's descriptor; ODUS never sends the full descriptor in its place. [Statement descriptors](https://docs.odus.com/docs/guides/products/overview.md#statement-descriptors) explains both fields. ## Credential verification When you create or update the gateway profile, ODUS verifies the **Secret Key** against Stripe before saving. If verification fails, the profile is not saved and an error is returned. --- # Setting up Webhooks Learn how to configure ODUS to receive webhook notifications from Stripe. ## Overview Webhooks allow Stripe to notify ODUS about events that happen asynchronously, such as failed refunds. When properly configured, ODUS will automatically process these notifications and update the corresponding payment records. note ODUS currently processes the following Stripe webhook events, all related to reversal failure detection: | Stripe event | Description | | ---------------- | --------------------------------------------- | | `refund.failed` | A refund has failed | | `refund.created` | A refund has been created (tracked if failed) | | `refund.updated` | A refund has been updated (tracked if failed) | ## Prerequisites Before setting up webhooks, ensure you have completed the general [Stripe integration](https://docs.odus.com/docs/guides/integrations/gateways/stripe/.md) and have a working gateway profile in ODUS. ## Step 1 — Get the webhook URL from ODUS When you create a new gateway profile in the ODUS Dashboard, a **Webhook Setup** dialog appears automatically after the profile is created. This dialog provides: 1. A **Webhook URL** — a read-only field with a copy button. This URL points to the ODUS webhook endpoint and includes your merchant and gateway profile identifiers as query parameters. Copy this URL; you will need it in the next step. 2. A **Webhook Verification Key** field — you will fill this in after configuring the webhook in Stripe (Step 3). tip If you skipped the webhook setup during gateway profile creation, you can still configure it later by editing the gateway profile in the ODUS Dashboard. The webhook URL and verification key fields are available in the edit form. ## Step 2 — Create a webhook endpoint in Stripe 1. Log in to the [Stripe Dashboard](https://dashboard.stripe.com/). 2. Navigate to **Workbench > Webhooks** (or go directly to `https://dashboard.stripe.com/webhooks`). 3. Click **Create an event destination**. 4. For the event source, select **Your account** (Stripe account). 5. Select the following **event types** to listen to: * `refund.failed` * `refund.created` * `refund.updated` 6. Click **Continue**, then select **Webhook endpoint** as the destination type. 7. Click **Continue** and paste the **Webhook URL** you copied from the ODUS Dashboard into the **Endpoint URL** field. 8. Save the configuration. important If you are using Stripe in **live mode**, the endpoint URL must use HTTPS. In test mode, HTTP is also accepted. ## Step 3 — Copy the signing secret to ODUS After creating the webhook endpoint in Stripe: 1. In the **Webhooks** section of the Stripe Dashboard, select the endpoint you just created. 2. Click **Click to reveal** to display the signing secret. It starts with `whsec_`. 3. Copy the signing secret. 4. Go back to the ODUS Dashboard — either in the Webhook Setup dialog (if still open) or by editing the gateway profile. 5. Paste the signing secret into the **Webhook Verification Key** field. 6. Click **Save**. warning Stripe generates a **unique signing secret per endpoint**. If you are using both test and live mode, each will have its own signing secret — make sure you use the correct one for the corresponding ODUS gateway profile. ## Important notes * Stripe retries webhook deliveries for up to **3 days** with exponential backoff in live mode. * Webhook events are **not guaranteed** to arrive in chronological order. * You can roll your signing secret periodically in the Stripe Dashboard. When doing so, Stripe allows a grace period of up to 24 hours during which both the old and new secrets are accepted. --- # Order Management System Order Management System turns successful payments for physical goods into orders and tracks them through fulfillment — from the moment a customer pays until every item is shipped or cancelled. note Order Management System is currently available in the **sandbox environment only**. ## How it fits together Orders are created for you: Order Management System is a webhook subscriber of the payments platform, so every successful payment reaches it as a `payment.succeeded` event. The line items of the payment that belong to shippable products become order items. From there you choose how to ship — record fulfillments manually, exchange files with your logistics provider, or let fulfillment routes submit orders to the provider automatically. If a payment is voided or fully refunded before shipping, the unshipped items are cancelled for you as well. note Fulfillment routes decide **where your goods ship from**. They are unrelated to [payment routing](https://docs.odus.com/docs/guides/routing/.md), which decides which payment gateway processes a payment. ## Guides in this section Follow them in this order when starting from zero: 1. [Setting up Order Management System](https://docs.odus.com/docs/guides/order-management/setup.md) — connect your payment events and create your first order. 2. [How orders are created](https://docs.odus.com/docs/guides/order-management/how-orders-are-created.md) — which purchases become orders, order bundling, and customer info sync. 3. [Order statuses](https://docs.odus.com/docs/guides/order-management/order-statuses.md) — what each order and item status means. 4. [Viewing and managing orders](https://docs.odus.com/docs/guides/order-management/managing-orders.md) — bulk actions and adding items to an order. 5. [Importing orders](https://docs.odus.com/docs/guides/order-management/importing-orders.md) — backfill orders from existing payments. 6. [Cancelling orders](https://docs.odus.com/docs/guides/order-management/cancelling-orders.md) — what happens when payments are voided, refunded, or fail to capture. 7. [Logistics integrations](https://docs.odus.com/docs/guides/order-management/integrations.md) — connect a 3PL provider. 8. [Inventory](https://docs.odus.com/docs/guides/order-management/inventory.md) — see the stock your providers hold for each SKU. 9. [Export and bulk fulfill](https://docs.odus.com/docs/guides/order-management/export-and-bulk-fulfill.md) — the file-based round-trip with your provider. 10. [Automatic fulfillment](https://docs.odus.com/docs/guides/order-management/automatic-fulfillment.md) — submit orders to your provider automatically. 11. [PayPal shipment tracking](https://docs.odus.com/docs/guides/order-management/paypal-shipment-tracking.md) — understand when fulfillment tracking is sent to PayPal. --- # Automatic Fulfillment Automatic fulfillment ships orders end to end without manual work: new orders are submitted to your [logistics integration](https://docs.odus.com/docs/guides/order-management/integrations.md) automatically, and when the provider ships, the fulfillment is recorded with carrier and tracking details. **Fulfillment routes** are the rules that decide which orders are submitted to which integration. ## How it works ## How routes are matched Routes are evaluated for every new order after its bundling window closes: 1. Published, active routes are checked in **priority order**. 2. A route matches when the order's shipping country is in the route's countries (empty list = any country) and the item's SKU is in the route's SKUs (empty list = any SKU). 3. A matching route is used only when its integration has the [whole order in stock](#stock-coverage). Otherwise the route is skipped and the next route by priority is checked. 4. Every shippable item of the order must resolve to the **same integration**. Orders whose items would go to different providers are left for manual fulfillment. A matched order is submitted to the provider automatically, within minutes of the bundling window closing. Temporary provider failures are retried automatically with increasing delays. An order your provider refuses because your prepaid balance can't cover it is put [On hold](https://docs.odus.com/docs/guides/order-management/order-statuses.md#held-for-your-prepaid-balance) and sent again once the balance covers it. An order ShipBob accepts and then holds — because it couldn't charge your payment method, can't validate the address, or for another reason — is put [On hold](https://docs.odus.com/docs/guides/order-management/order-statuses.md#held-by-shipbob) until ShipBob releases it. Orders that no route matches — or that cannot be submitted, for example because the integration was archived — simply stay in the orders list for manual fulfillment. ## Stock coverage A route is used only when its integration has **every shippable item of the order in stock, in the quantities ordered**. Otherwise the route is skipped and the next route by priority is checked, so an order is never split between providers and never submitted to a provider that is short of an item. There is nothing to configure: every route works this way. Stock is read from the same figures the [Inventory](https://docs.odus.com/docs/guides/order-management/inventory.md) page shows, so a SKU the integration reports no figure for — not stocked there, or [not mapped](https://docs.odus.com/docs/guides/order-management/inventory.md#mapping-a-sku-to-a-provider) to it — counts as not in stock. For example, with a route for US orders of product A going to a preferred provider ahead of a catch-all route, a US order containing products A and B matches the preferred route first. If that provider has no stock of B, the route is skipped and the whole order goes to the catch-all provider — which must itself have both A and B in stock, since every route is held to the same rule. When a route is skipped, the order's activity names the route and the SKUs its integration was short of, so you can see why an order went to a lower-priority provider or stayed unfulfilled. If the integration's stock could not be read — the provider did not answer when the inventory figures were last refreshed — the order is neither routed past that route nor submitted blind. It is retried later, like a temporary provider failure. ## What each provider needs from an order An order is only submitted once it carries everything the provider requires. Orders missing required details are left for manual fulfillment instead of being retried, and the reason is shown on the order. **NextSmartShip** is the stricter of the two. Every order it receives must have a shipping address with a **first and last name**, a **phone number**, a **country**, a **city**, a **postal code** and a **street address**. Orders missing any of these are not submitted. The exceptions are a postal code or a city that the country alone decides. Order Management System fills these in, so you don't need to edit the address first: * **No postal code, to a country without postal codes**, such as the United Arab Emirates or Qatar: the order is sent with the postal code `00000`. * **No city, to a country that is a single city**, such as Singapore: the order is sent with the country's name as its city. Only what is sent to NextSmartShip is filled in. The order keeps the address as your customer entered it. An [export](https://docs.odus.com/docs/guides/order-management/export-and-bulk-fulfill.md) in NextSmartShip's format writes the same values. NextSmartShip also requires a shipping service to be chosen for each order rather than picking one itself. Order Management System requests the available services for the destination and selects the **cheapest** one. If NextSmartShip returns no services at all for an address, the order cannot ship to that destination and is left for manual handling. An order NextSmartShip accepts but then cannot find a carrier for is put [on hold](https://docs.odus.com/docs/guides/order-management/order-statuses.md#on-hold-orders), with the reason shown on the order; correct the customer, and for most reasons the order is [resubmitted for you](https://docs.odus.com/docs/guides/order-management/managing-orders.md#resubmitting-an-order-your-provider-put-on-hold). ### Details NextSmartShip is likely to hold an order for If you have an active NextSmartShip integration, Order Management System checks each order's customer details before NextSmartShip sees them, and marks an order that falls short [On hold](https://docs.odus.com/docs/guides/order-management/order-statuses.md#likely-to-be-held-by-your-provider) — a missing required detail from the list above, or a detail that breaks one of these rules: * The name, the company and each address line fit in 35 bytes, and both address lines together fit in 40. Middle names are dropped and common street words are abbreviated to make a name or an address fit, so only one that is still too long is marked. A company is never shortened. * The name is at least 5 bytes long. * Limits count bytes rather than characters. That matters only for text kept in a script with no Latin form, where each character takes two to four bytes. * The postal code is a valid postal code for the country. * In Australia, Canada and the United States, the city is a place the postal code covers, and the state is one the state or postal code identifies. * The name, company, address lines and city can be printed in Latin letters. * The first address line, the city and the postal code are not placeholders such as `N/A` or `test`. An order that only breaks these rules is still submitted on time, but NextSmartShip is likely to put it on hold. Correct the customer before the bundling window closes to avoid the hold. To check an order yourself without submitting it, call `GET /orders/{orderId}/recipient-validation?integrationType=nextsmartship` in the [Order Management API](https://docs.odus.com/docs/api/oms/odus-oms-api). ## Publishing and pausing routes Open a route to manage its lifecycle from the **Actions** menu: * **Publish** — makes a draft route live. Only published routes are evaluated. * **Deactivate** / **Activate** — temporarily pauses a route (for example during a provider outage) without losing its configuration. ## Priorities When routes overlap — say, a Europe-wide route and a Germany-specific one — the route with the higher priority (lower number) wins. On the **Rules** tab, sort by priority to drag-and-drop routes into the right order. ## The Coverage tab The **Coverage** tab shows a matrix of countries × SKUs, with each cell showing the integration that would handle that combination — or a dash where no published route applies. Use it to spot gaps in your routing table before customers hit them. tip Review the Coverage tab after every route change. A missing cell means orders for that country/SKU combination will wait for manual fulfillment. --- # Cancelling Orders Cancellation removes units from an order that will not ship. It happens in two ways: automatically, when a payment is reversed or its capture fails, and manually, from the order detail page. This guide covers both — and the partial-refund case that connects them. ## Automatic cancellation When a payment is **voided** or **fully refunded**, Order Management System cancels the remaining unshipped quantities of every order item created from that payment. Units that already shipped stay fulfilled — an item that shipped partially ends up `Partially Cancelled`. See [Refunds and Voids](https://docs.odus.com/docs/guides/payments/refunds.md) for how reversals work on the payments side. The same happens when a payment **capture fails**: the remaining unshipped quantities of that payment's items are cancelled automatically. A payment that is voided, fully refunded or fails capture before its order is created gets no order, so nothing from it reaches your logistics provider, and you cannot [import](https://docs.odus.com/docs/guides/order-management/importing-orders.md) it later either. If the order is being created at the moment the reversal arrives, the payment's items are still added to an order and then cancelled straight away, as described above, so you see the cancelled items rather than no order at all. In a [bundled order](https://docs.odus.com/docs/guides/order-management/how-orders-are-created.md#order-bundling), only the items of the reversed payment are affected. Items from the customer's other payments continue toward fulfillment as normal. Chargebacks do **not** cancel orders. ## Partial refunds important A **partial refund does not cancel anything automatically.** Refunds are amount-based, so Order Management System cannot tell which order items a partial refund corresponds to. Cancel the affected items manually. The recommended workflow when a customer returns part of an order: 1. Issue the partial refund in the **Payments** view, as usual. 2. Open the order in the **Orders** view and cancel the items and quantities the refund covers via **Actions > Cancel order**. ## Orders already sent to your provider When an order that **NextSmartShip** or **ShipBob** has is cancelled in full — every unit cancelled, none shipped — Order Management System cancels it at the provider too, within a few minutes. This happens however the order was cancelled: automatically, or from the order detail page. The order's activity records what the provider answered: * The order was cancelled there and will not ship. * Only part of the order was cancelled there. If the rest has not shipped, cancel it at your provider yourself. * The cancellation was passed to a warehouse that had already started on the order, and the warehouse decides whether it can still stop it. * The order was not cancelled there: the provider had already started shipping or had closed it, or the request did not go through, and the activity says why. If the order has not shipped, cancel it at your provider yourself. warning An order you cancel only in part is not cancelled at your provider. Cancel the units there as well, or the provider can still ship them. If the provider ships cancelled units anyway, the order is marked [`Shipped after cancellation`](https://docs.odus.com/docs/guides/order-management/order-statuses.md#shipped-after-cancellation), and its activity records each shipment with its tracking number. --- # Export and Bulk Fulfill Export and Bulk Fulfill form a file-based round-trip with your logistics provider: download your open orders as a file the provider's platform can import, let the provider ship them, then upload the provider's shipment report to mark the orders as fulfilled — hundreds at a time. Both actions live in the toolbar of the **Orders** page, and both run in the background: you can close their dialog and find every export and bulk fulfill you started, with its outcome, under [Order jobs](#order-jobs). Provider-specific formats become available once you add the matching [logistics integration](https://docs.odus.com/docs/guides/order-management/integrations.md). ## Exporting orders 1. Filter the orders list to the orders you want to export — the export includes exactly the currently filtered orders. 2. Click **Export** and choose a **Format**: * **Standard** — a general-purpose CSV. Choose whether rows are **Orders** or **Order Items** and select the columns to include. * **NextSmartShip** — one XLSX file in the provider's bulk-import layout, ready to upload into the provider's platform. * **ShipBob** — a ZIP archive of XLSX files in ShipBob's bulk-import layout, each holding up to 999 orders. ShipBob accepts at most 1,000 orders per upload, so unpack the archive and import the files one at a time — every file is complete on its own, and they are numbered in the order the orders were exported. 3. Click the export button. The export runs in the background: the dialog shows its progress and offers **Download** as soon as the file is ready. You can close the dialog while it runs — the export carries on, and its file is waiting under [Order jobs](#order-jobs). One export runs at a time per account. Starting another while one is still running is refused; wait for it to finish, then start the next. A provider-format export carries at most **50,000 rows**. An export that would exceed that fails with a message saying so; narrow the filters — by date, for example — and export in parts. A Standard CSV export has no row limit. note Provider-format exports reference items by product name (ShipBob) or SKU (NextSmartShip), except for the products you [mapped](https://docs.odus.com/docs/guides/order-management/integrations.md#product-matching) to something else. A product without a mapping is exported under its usual value, so no order is left out for want of one. Before the export starts the dialog says how many of the filtered orders will be written; the only ones skipped are those with nothing shippable on them. ## Bulk fulfilling orders Once the provider has shipped, download the shipment report from the provider's platform and upload it via **Bulk Fulfill**. The dialog shows a preview — the format, the number of rows, and the parsed data — before anything is applied. Every bulk fulfill runs in the background, whichever format you chose: the dialog shows its progress and then the [result report](#result-report). Closing the dialog does not stop the fulfillment — the job stays listed under [Order jobs](#order-jobs) with its status — but the per-shipment report is shown only in the dialog, so leave it open if you want to read it. One bulk fulfill runs at a time per account, and one file carries at most 10,000 rows. Starting another while one is still running is refused, and a longer file has to be split and uploaded a part at a time. Row count is not the only limit. The rows are sent to ODUS as data rather than as the file itself, and that request is capped at 4 MB — so a file with many columns can be refused below 10,000 rows, whatever the file's own size on disk. Remove the columns the chosen format does not use, or split the file. ### Standard format The **Standard** format is a CSV you prepare yourself, with one row per shipment: an **Order ID**, **Tracking Number** and **Carrier** column, and optionally an **Order Item ID** column. Without the item column, every unfulfilled item on the order ships with that tracking number; with it, only the listed items do — and one file uses one of the two, not a mix. Rows without a tracking number or carrier are skipped before anything is uploaded, and the tracking URL is derived from the carrier and tracking number. ### Provider formats Choose the provider's format and upload the file exported from the provider's platform: * **NextSmartShip** — the platform's fulfilled-orders XLSX export. Orders are matched by the **Platform Order No.** column, and the last-mile carrier and tracking number columns fill in the shipment details. * **ShipBob** — the platform's shipment report. Orders are matched by the **Store Order ID** column, with tracking number, carrier, and tracking URL taken from the report. tip The Bulk Fulfill dialog shows each format's required and optional columns before you upload, so you can verify your file's layout first. ## Result report Once the fulfillment finishes, the dialog summarizes how many fulfillments succeeded, failed, or were skipped, and lists per-order details for every failure or skip — an unknown order ID is reported as a failure, an item that was already fulfilled as a skip. Fix the reported rows and re-upload just those if needed. ## Order jobs Open the **Jobs** tab on the **Orders** page to see every export and bulk fulfill run as a background job for your account — everything started from the Orders page, and everything started through the API's job endpoints — newest first. The tab is available if your role allows exports or bulk fulfills. Each row says what kind of job it was, what it was given (an export's format and filters; a bulk fulfill's format and number of rows), when it started, and whether it is running, completed or failed; a running job shows its progress. The actions follow the kind: * **Download**, on a completed export, returns the file it produced: the orders as they were when it ran. A file is kept for **90 days** after the export completes; after that the row says **File expired**. * **Re-run**, on an export, starts a new export with the same format and filters against your orders as they are **now**. Its file can differ from the original — orders created, fulfilled or cancelled since then are reflected. * **Re-run**, on a bulk fulfill, is offered only when it **failed**. It runs the same uploaded rows again; shipments the first run already recorded are skipped, so only what it did not get to is fulfilled. The uploaded rows are kept for **90 days** after the bulk fulfill starts; after that the row says **Rows expired** and the file has to be uploaded again. A completed bulk fulfill has nothing to re-run. A job that failed stays listed. Hover over or focus the info icon next to its status to read why, then adjust the filters or the file and run it again. --- # How Orders Are Created Orders are created from webhooks: Order Management System is a webhook subscriber of the payments platform, and every order starts as a `payment.succeeded` event received through the subscription created when you [enabled the addon](https://docs.odus.com/docs/guides/order-management/setup.md). This page explains what happens from there: which purchases become orders, which of them ship, when multiple payments are bundled into one order, and how customer details stay in sync. ## From payment to order When the `payment.succeeded` event for a payment arrives, each purchased line item is read from the product behind its price: * A line item whose product is marked **Shippable** becomes an **order item**. * Line items of products that are not shippable are skipped. * If the payment contains no shippable product, **no order is created**. The number of units to ship is the quantity on the payment's line item. A price that represents several units is already expanded there when the payment is created — a 3-pack price purchased twice arrives as six units — so the order item ships exactly what the payment says. Products are configured in the **Payments** view under **Products**; see [Creating products and prices](https://docs.odus.com/docs/guides/products/creating-products-and-prices.md). ## What the SKU decides **Shippable** only decides whether Order Management System sees a purchase at all. Whether the order item it produces is something your warehouse actually ships is decided by whether the product has a **SKU**: | Product SKU | The resulting order item | | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Set | **Ships**, unless the price it was bought at [opts out](#excluding-one-price-from-shipping). It carries the product's SKU and a unit count, it is what fulfillments and cancellations are recorded against, and it is included in exports and submissions to your 3PL provider. | | Not set | **Never ships.** It is recorded on the order for visibility — a warranty, shipping protection, or a service fee sold alongside goods — and is left out of fulfillment, exports, and submissions. | An item that never ships appears as `Not Shippable` on the order, carries no quantities, and is ignored when the order's own status is calculated. An order made up entirely of such items is `Not fulfillable` — there is nothing to ship. See [Order statuses](https://docs.odus.com/docs/guides/order-management/order-statuses.md#order-item-statuses). note **Category** plays no part in any of this. It is sent as metadata to payment service providers — for example for VAT reporting on the split between digital services and physical goods — and never affects whether an item ships. ### Excluding one price from shipping Several prices often share one product: the goods themselves, and add-ons sold alongside them that must never reach your warehouse — shipping protection, a service fee. Clearing the product's SKU is no help there, because the goods sold under the same product need it. Exclude the add-on on its **price** instead. Give the price a `fulfillable` key with the value `false` in its [metadata](https://docs.odus.com/docs/guides/api-features/metadata.md), and every item bought at that price is treated exactly as an item whose product has no SKU: recorded as `Not Shippable`, and left out of fulfillment, exports and submissions to your 3PL provider. An order made up entirely of such items is `Not fulfillable`. Set it on the price under **Products → Prices**, or send it through the API when you [create or edit the price](https://docs.odus.com/docs/guides/products/creating-products-and-prices.md#editing-a-price): ``` { "metadata": { "fulfillable": "false" } } ``` The value must be exactly `false`. A price without the key, or with any other value, leaves the product's SKU deciding, so nothing changes for the prices you never mark. Marking a price takes effect for orders created from then on, including orders you [import manually](https://docs.odus.com/docs/guides/order-management/importing-orders.md) later — an order that already exists keeps the details captured when it was created. Why is this item not fulfillable? An item you expected to ship came out `Not Shippable` for one of two reasons: the product behind it has no **SKU**, or the price it was bought at is marked `fulfillable: false`. Correcting either applies to every order created from then on, but an order that already exists keeps the details captured when it was created — arrange those shipments outside Order Management System. ### The SKU is the identifier your warehouse ships against The SKU is copied onto every shippable order item created from the product, and it is what exports and 3PL submissions identify the item by. An add-on with a product of its own — a warranty, shipping protection — needs no SKU: nothing ships, so there is nothing to identify. Leave it empty for those, and mark the price instead when the add-on shares its product with goods that do ship. ## Order bundling When a customer makes several purchases in quick succession — for example, an upsell right after the initial purchase — you may prefer to ship them together. The **Bundling window (minutes)** setting under **Settings > General** in the Orders view controls this. A new payment is added to an existing order instead of creating a new one when **all** of the following hold: | Criterion | Description | | ---------------- | -------------------------------------------------------------- | | Customer | The payment belongs to the same customer as the existing order | | Shipping address | The shipping address is identical | | Order state | The existing order is still completely unfulfilled | | Time | The existing order was created within the bundling window | With the window set to `0`, bundling is disabled and every payment creates its own order. Example The bundling window is set to `30`. A customer buys a subscription box at 10:00 and accepts an upsell at 10:12. Both purchases share the shipping address, so the upsell items are appended to the 10:00 order and ship together. ### Bundling and automatic fulfillment Orders are handed to [fulfillment routes](https://docs.odus.com/docs/guides/order-management/automatic-fulfillment.md) only after the bundling window has closed. This guarantees a bundled order is submitted to your logistics provider as one complete shipment, not item by item. ## Customer information sync When a customer's details change in the payments platform — for example, they correct their email or shipping address — Order Management System refreshes the customer name, email, phone, and shipping address on their orders. Orders that have already shipped, or that have been handed to a logistics provider, keep the address they shipped to. --- # Importing Orders Import creates orders retroactively from payments that already exist. Use it when an order was never created automatically — most commonly because the payment event did not reach Order Management System — or when you enable Order Management System after you have already been taking payments. ## Importing 1. In the **Orders** view, open **Orders** and click **Import**. 2. Choose a mode: * **Separate orders** — one order is created per payment. * **Combined order** — the items of all listed payments are merged into a single order. All payments must belong to the same customer and use the same currency. 3. Paste the payment IDs (`pay_…`), separated by new lines, commas, or spaces. Up to 100 payments can be imported at once. 4. Click **Import**. Payments are read exactly like [automatically created orders](https://docs.odus.com/docs/guides/order-management/how-orders-are-created.md): only line items whose product is marked **Shippable** become order items. note The [bundling window](https://docs.odus.com/docs/guides/order-management/how-orders-are-created.md#order-bundling) does not apply to imports. The grouping is exactly what you choose — one order per payment, or one combined order. ## Eligibility Each payment must: * be succeeded, * not be reversed or chargebacked, * not have failed capture, * not already have order items in the system, and * contain at least one line item whose product is marked **Shippable**. --- # Logistics Integrations A logistics integration connects Order Management System to a third-party logistics (3PL) provider — the company that stores and ships your physical goods. Integrations power the [file-based fulfillment round-trip](https://docs.odus.com/docs/guides/order-management/export-and-bulk-fulfill.md) and, where supported, [fully automatic fulfillment](https://docs.odus.com/docs/guides/order-management/automatic-fulfillment.md). ## Supported providers | Capability | ShipBob | NextSmartShip | | --------------------- | ------- | ------------- | | Order export format | ✓ | ✓ | | Bulk fulfill format | ✓ | ✓ | | Automatic fulfillment | ✓ | ✓ | Both providers support [automatic fulfillment](https://docs.odus.com/docs/guides/order-management/automatic-fulfillment.md) — see that guide for how submitted orders flow to your provider and come back fulfilled. ## Connecting a provider Create an integration from **Settings > Integrations** in the **Orders** view. Switch on **API connection** to let Order Management System talk to the provider's API — that is what reveals the credential fields, and what every live capability depends on: automatic fulfillment, stock levels on the [Inventory](https://docs.odus.com/docs/guides/order-management/inventory.md) page, and shipment updates coming back by webhook. Each provider asks for its own credentials: * **ShipBob** — an **API Key**, plus the **Environment** (sandbox or production) the key belongs to. * **NextSmartShip** — a **Client ID** and **Client Secret**. These are the API credentials from your NextSmartShip account; Order Management System exchanges them for a short-lived access token, then reuses that token across submissions until it expires. NextSmartShip has no sandbox, so integrations always run against production. Credentials are verified against the provider the moment you save, so an incorrect value is reported immediately rather than surfacing later as failed orders. ## Completing the API connection Shipment updates flow back to Order Management System through a webhook — it is how the system learns that the provider shipped an order and records the fulfillment with its tracking details. How that webhook is connected depends on the provider. ### NextSmartShip Nothing to do. The webhook is registered with NextSmartShip automatically when you save the integration, and the signature is verified using the Client Secret you already provided. ### ShipBob After you create an integration with the **API connection** switched on, its detail page shows a **Webhook URL** with a copy button. To connect it: 1. Copy the **Webhook URL** and register it as a webhook endpoint in your ShipBob account. 2. ShipBob issues a signing secret for that webhook. Copy the secret from its webhook settings. 3. Paste it into the integration's **Webhook Secret** field in Order Management System and save. Order Management System uses the secret to verify that incoming shipment updates genuinely come from ShipBob. ## Product matching When you [export orders](https://docs.odus.com/docs/guides/order-management/export-and-bulk-fulfill.md) in a provider's format — or when orders are submitted automatically — each item must reference a product the provider can find in its own catalog. Each provider matches products by a different value: * **ShipBob** matches by **product name** — your product name must match the product name in your ShipBob catalog. * **NextSmartShip** matches by **SKU** — your product SKU must match the product SKU in NextSmartShip. This happens on its own and needs no setup. Every product is matched by that value, and it is all you need when the two catalogs already agree. Where one product differs — the provider knows it under another name or another SKU — map that product. A mapping links one of your SKUs to the value the provider expects, and exports and automatic submissions send the mapped value **for that SKU only**. Every other product keeps being matched by the provider's usual value, so no order is ever left out for want of a mapping. Mappings are created, edited and removed from the SKU itself, on its page in [Inventory](https://docs.odus.com/docs/guides/order-management/inventory.md#mapping-a-sku-to-a-provider) — the SKU is context there rather than something to re-type, and the stock the mapping unlocks appears on the same page. note Removing a mapping is safe for orders: the item simply goes back to being matched by your product name (ShipBob) or your internal SKU (NextSmartShip). Stock reporting is stricter for ShipBob — an unmapped SKU shows no ShipBob stock figure — see [Inventory](https://docs.odus.com/docs/guides/order-management/inventory.md#opening-a-sku). --- # Inventory The **Inventory** page shows how many units of each SKU your logistics providers are holding right now, side by side in one table. Use it to spot the SKUs that are running out before orders start waiting for stock. Prerequisites * At least one [logistics integration](https://docs.odus.com/docs/guides/order-management/integrations.md) that is **active**, has its **API connection** switched on, and has its credentials saved. Integrations that miss any of the three are not shown. * Products marked **Shippable** and **Active**, each with a **SKU**. ## Opening the page In the **Orders** view, open **Inventory**. Every Order Management role — admin, developer, operator, support agent and viewer — can read the page. If you have no integration with an API connection yet, the page says so and links you to **Integrations** instead of showing an empty table. note If you enabled Order Management before Inventory shipped, your API key predates the permission Inventory needs to read your product catalog. Order Management grants it to itself the first time the page is opened, so usually there is nothing to do. Granting it needs a dashboard role that can manage API keys, so if yours cannot, the page asks you to repair the addon — ask an admin to open Inventory once, or to click **Repair** on **Order Management** under **Settings > Addons**. See [repairing the addon](https://docs.odus.com/docs/guides/order-management/setup.md#repairing-the-addon). ## What the table shows There is one row per SKU. Stock belongs to the SKU, not to a catalog product: two products that share a SKU describe the same physical units, so they appear once, with one total and one status. The SKU's own page lists every product that carries it. | Column | What it shows | | ------ | --------------------------------------------------------- | | SKU | The SKU, as your warehouse ships it | | Total | The units available across every connected provider | | Status | Whether the total is healthy, running low, or unavailable | To find a product in the list, use the **Product** filter — see [finding what needs attention](#finding-what-needs-attention). A **Total** is only ever a figure your providers actually reported. Where none of them did, it reads **—** rather than a number, and the status beside it says why: a provider that cannot be reached leaves the total **unknown, not zero** (see [when a provider cannot be reached](#when-a-provider-cannot-be-reached)), and a SKU no provider has been told about reads **Not mapped** (see [when a SKU is not mapped](#when-a-sku-is-not-mapped)). The list stays deliberately short — it answers "is this running out?" and nothing else. Which warehouse holds what, and which products carry the SKU, are follow-up questions, and live on the SKU itself. A shippable product with no SKU is left out of the list: without a SKU there is nothing to reconcile against a warehouse. ## Stock status The status is a traffic light calculated from the **Total** column against that SKU's **low-stock threshold**: | Status | Total units | | ---------------- | ------------------------------------------------------------------------ | | **Out of stock** | 0 — a provider answered, and the answer is none left | | **Low stock** | 1 up to and including the threshold | | **In stock** | more than the threshold | | **Unknown** | a provider did not answer and what was read is at or below the threshold | | **Not mapped** | no provider reports this SKU at all, and none of them has it mapped | **Out of stock** and **Not mapped** are deliberately different answers. The first is a warehouse telling you the shelf is empty; the second is that no warehouse was ever asked, because nothing knows the SKU. Only one of them is fixed by ordering more stock. Every SKU starts on the merchant-wide default of **10 units**, and you can give any SKU a threshold of its own — see [setting a low-stock threshold](#setting-a-low-stock-threshold). A pallet item and a fast-moving consumable rarely want the same reorder point, which is what makes the traffic light worth reading. The status is worked out when you read the page, not when the stock snapshot was taken, so a threshold you have just changed takes effect immediately — including for the **Stock status** filter and the **Status** sort. ## Opening a SKU Selecting a row opens that SKU's own page at `/inventory/`. It is an ordinary URL: reload it, bookmark it, or paste it into a support thread. It shows the combined total, the stock status, the threshold behind it, and one line per connected provider under **Stock by integration**: | Column | What it shows | | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Integration | The integration, as you named it | | Matched as | The value that provider is asked about for this SKU — your [product mapping](#mapping-a-sku-to-a-provider) where you have one; otherwise **Matched by internal SKU** for a provider that matches by SKU, or **Not mapped** for one that does not | | Stock | The units that provider reports | The **Stock** figure carries one of three things: * a **number** — what the provider reports; * **—** — the provider reports nothing for this SKU: it holds no record under the value it was asked, or it was not asked because the SKU is **Not mapped** to it; * **N/A** — the provider could not be reached. If it is one the SKU depends on (see [when a provider cannot be reached](#when-a-provider-cannot-be-reached)), its stock is **unknown, not zero**, and the total above reads **—** rather than a figure that leaves it out. Providers differ in what they are asked without a mapping, because a SKU can be carried by more than one product: * **NextSmartShip** matches by SKU, so an unmapped SKU is still asked for under your own SKU and reads **Matched by internal SKU**. An empty **Stock** figure beside it means NextSmartShip holds nothing under that SKU: check that the two catalogs spell it the same way, or [map the SKU](#mapping-a-sku-to-a-provider) if the provider knows it as something else. * **ShipBob** matches by product name, and a SKU shared by two products has no single name to ask for. An unmapped SKU therefore reads **Not mapped** with no stock figure, and ShipBob's stock only counts towards the total once you [map the SKU](#mapping-a-sku-to-a-provider). Orders are not affected: exports and automatic submissions keep matching an unmapped item by its product name, as described under [product matching](https://docs.odus.com/docs/guides/order-management/integrations.md#product-matching). warning If a SKU is held by ShipBob alone and you have not mapped it, its row reads **Not mapped** rather than a figure. Map the SKU on its page and the figure appears on the next refresh, which happens as soon as the mapping is saved. The stock figures come from the same snapshot as the list, so the two can never disagree. Below **Stock by integration**, **Products with this SKU** counts and lists every product in your catalog that carries the SKU. Select a product to open it — that is where its name, price and SKU are edited. A SKU that no product carries says so instead of showing an empty list. ## Mapping a SKU to a provider Each provider row carries its own mapping. A mapped row offers **Edit** and **Remove**; an unmapped one offers to create the mapping. The SKU is not asked for — you are already on its page, so only the provider's half of the mapping is a choice: * for a provider that publishes a catalog and has an API key stored, you pick from that catalog, and ODUS remembers the product's id and SKU alongside the name; * otherwise you type the name or SKU exactly as that provider spells it. What the action is called says what the mapping does. On a **NextSmartShip** row it reads **Map manually**, because there is nothing to repair: the SKU is already matched by your internal SKU, and a mapping is the **override** for a SKU that provider spells differently. On a **ShipBob** row it reads **Map product**, because the mapping is what brings that provider's stock figure in. Either way it applies to that SKU alone — the dialog says which value it replaces — and every other SKU goes on being matched exactly as before. The stock snapshot is rebuilt as soon as a mapping changes, so the row's **Stock** figure is right by the time the dialog closes. Removing a mapping does not delete anything at the provider — it only stops ODUS claiming the two are the same product. Orders with that SKU go back to being matched by your product name or your internal SKU, whichever that provider [matches by](https://docs.odus.com/docs/guides/order-management/integrations.md#product-matching), so they keep exporting either way. On the Inventory page a NextSmartShip row goes back to **Matched by internal SKU**; a ShipBob row reads **Not mapped** until you map it again, and the confirmation says so. Mapping needs a dashboard role that can change product mappings. A read-only role sees the **Matched as** column but is offered no action. ## Setting a low-stock threshold On a SKU's page the threshold is shown beside the total as **Low stock threshold**, with **Edit** next to it. It is the number of units at or below which the SKU counts as **Low stock** — the status you see on the SKU and can filter the list by. An empty stock is always **Out of stock**, whatever the threshold, and nothing is sent when a SKU crosses either line. The threshold must be a whole number, and `0` is allowed — it means the SKU is never **Low stock**, only **In stock** or **Out of stock**, which suits a made-to-order item. Every SKU starts on the merchant-wide default. Edit the value to give this SKU a threshold of its own; a SKU you never touch keeps using the default. The status updates as soon as you save, and the list agrees with it on the way back. Setting a per-SKU threshold needs a dashboard role that can write to inventory, so both admins and operators can do it. Everyone who can read the page can see the threshold. The merchant-wide default is a different setting, and changing it still needs a role that can change Order Management configuration. note A threshold belongs to the **SKU**, not to one catalogue product. If two products share a SKU they describe the same physical stock, and one threshold covers both. ## Finding what needs attention The list behaves like the other Order Management lists. Filter it by **SKU**, **Product** or **Stock status**, sort it by **SKU**, **Total** or **Status**, and page through the results. The **Product** filter is a picker over your catalog: it lists your products a page at a time as you scroll, and typing searches them by name, so a product far down a long catalog is found without scrolling to it. Pick as many products as you want and the list shows the SKU rows they are on — where two products share a SKU, picking either shows that one row, and picking both still shows it once. The filter names the products, and the list stays one row per SKU. Sorting by **Status** ascending brings the SKUs that need attention to the top: out of stock first, then low stock, then the ones whose total is unknown, then the ones nothing is mapped to. **Stock status** filters on **Unknown** and **Not mapped** too, which is how you list everything a provider outage is currently hiding, or every SKU still waiting to be mapped. A **Total** filter leaves both out: there is no number to compare. ## Keeping the figures current The numbers come from a stored snapshot that is refreshed at most every 15 minutes, so the page stays fast even with several providers connected. The page shows when the snapshot was last updated, and **Refresh** asks every provider again. ### When a provider cannot be reached Its stock reads **N/A** on every SKU, and every **Total** that depends on it reads **—** with a status of **Unknown** — never a figure or an **Out of stock** you would act on. A provider that is merely slow to answer must not empty your catalog on screen. A SKU depends on a provider that would be asked about it: one the SKU is [mapped](#mapping-a-sku-to-a-provider) to, or NextSmartShip, which is asked by the SKU itself. A ShipBob integration the SKU is **not** mapped to reports nothing for that SKU whether or not it can be reached, so its outage changes nothing for that SKU — the total and status stay as they were, and a SKU that reads **Not mapped** keeps reading **Not mapped**. The verdict is still given wherever the missing warehouse could not change it: stock already above a SKU's threshold is **In stock** whatever else turns up, because the unreported stock can only add to it. So one provider blipping greys out the SKUs that were near the line, not all of them. Refresh once the provider is reachable again, and the figures come back. ### When a SKU is not mapped If no connected provider reports a figure for a SKU and none of them has it mapped, the row reads **Not mapped** with a **Total** of **—**. Nothing is out of stock: no provider recognised the SKU, or none was asked about it. Select the row to open the SKU, where every provider row says how it is matched and offers to map it — see [mapping a SKU to a provider](#mapping-a-sku-to-a-provider). The figures appear on the next refresh, which happens as soon as the mapping is saved. While a provider the SKU depends on is unreachable — NextSmartShip, or a provider the SKU is mapped to — the row reads **Unknown** instead, because that provider might have recognised the SKU. Refresh once it is reachable again to learn which it is. An unreachable ShipBob integration the SKU is not mapped to makes no difference: the row stays **Not mapped**, and mapping the SKU is still what changes it. ## Read-only by design Inventory only reads from your providers. Order Management System never writes stock levels back to a 3PL, so counts, corrections and restocks are always made in the provider's own platform and appear here on the next refresh. --- # Viewing and Managing Orders The **Orders** page is the operational center of Order Management System: find the orders that need action, inspect their details, and work them through fulfillment. ## Bulk actions The toolbar of the **Orders** page offers three bulk actions: * **Import** — create orders from existing payments; see [Importing orders](https://docs.odus.com/docs/guides/order-management/importing-orders.md). * **Export** — download the filtered orders as a file; see [Export and bulk fulfill](https://docs.odus.com/docs/guides/order-management/export-and-bulk-fulfill.md). * **Bulk Fulfill** — mark many orders as shipped from a file; see [Export and bulk fulfill](https://docs.odus.com/docs/guides/order-management/export-and-bulk-fulfill.md). ## Adding items to an order While an order is still `Unfulfilled` and has not been sent to your logistics provider, you can merge items from additional payments into it — for example, when a customer places a follow-up purchase that was not [bundled](https://docs.odus.com/docs/guides/order-management/how-orders-are-created.md#order-bundling) into it, and you want to ship everything together. 1. Open the order and choose **Actions > Add items**. 2. Paste up to 10 payment IDs (`pay_…`), one per line. 3. Click the confirm button. Each payment's line items that belong to a product in Order Management System are appended to the order. Every added payment must share the order's customer and currency, must be succeeded, must not be reversed or have failed capture, and must not already have order items elsewhere. With [automatic fulfillment](https://docs.odus.com/docs/guides/order-management/automatic-fulfillment.md), an order is sent to your logistics provider when its bundling window closes, so add items before then. Once sending has started, items can't be added, even if your provider has not accepted the order yet: your provider would never receive them. [Import the payments](https://docs.odus.com/docs/guides/order-management/importing-orders.md) as an order of their own instead. ## Correcting an order before your provider holds it When an order's customer details break one of your logistics provider's rules, the order is marked [On hold](https://docs.odus.com/docs/guides/order-management/order-statuses.md#likely-to-be-held-by-your-provider) and its page lists each detail to correct — for example, that the city does not match the postal code. 1. Open the customer from the order page and correct what the list names: their name, phone number or shipping address. 2. Back on the order page: * **If the order has not been sent yet**, check that the notice is gone. `On hold` clears once every detail is fixed, and your provider receives the corrected details when the order is sent. Correct the customer before the order's bundling window closes to get them there the first time. * **If the order has been sent**, click **Resubmit** in the notice. You don't have to wait for your provider to hold the order. The dialog shows the details about to be sent. Your provider accepts a correction only while it is holding the order. If it isn't holding it yet, the order page says so; resubmit once it does. A correction made before your provider holds the order is not resubmitted for you. Resubmitting follows the same rules as [resubmitting an order your provider put on hold](#resubmitting-an-order-your-provider-put-on-hold). ## Resubmitting an order your provider put on hold When your logistics provider accepts an order and then cannot find a carrier to take it as addressed, it puts the order [on hold](https://docs.odus.com/docs/guides/order-management/order-statuses.md#on-hold-orders) and waits. The order page says why the order is held and what to correct — for example, that the city does not match the postal code. 1. Fix what the reason names on the **customer**: open the customer from the order page and correct their name, phone number or shipping address. A held order picks the correction up; an order that is not on hold keeps the details it was submitted with. 2. Wait a few minutes. The order is resubmitted for you with the corrected details, and so is every other order of that customer your provider is holding. The order page then says it is waiting for your provider. warning Correcting a customer resubmits every order of theirs your provider is holding, not only the one you opened. If one of them must not ship — you shipped it some other way, or it should be cancelled — record that on the order before you correct the customer: an order that has shipped or cancelled anything is never resubmitted. A few reasons are not fixed on the customer — such as a missing tax ID, which you complete in your provider's dashboard — and the order page says so. Those orders are not resubmitted for you; [Held by your provider](https://docs.odus.com/docs/guides/order-management/order-statuses.md#held-by-your-provider) lists which. Resubmit them yourself once the reason is dealt with — and any other held order you do not want to wait for: 1. On the order page, click **Resubmit** in the hold notice. The dialog shows the details about to be sent, so you can check the correction is in. 2. Confirm. The order is resubmitted with its current details and the shipping service is chosen the same way as for the first submission. The provider answers in its own time, usually within minutes. The order stays `On hold` and says it is waiting until then; it then either clears the hold or holds the order again with a new reason, in which case you can correct and resubmit once more. Every hold, resubmission and release is listed in the order's activity. Resubmitting needs the permission to update orders. For a ShipBob order it is offered only when ShipBob can't validate the address, and it sends ShipBob the address rather than the order: see [Sending an address to ShipBob](#sending-an-address-to-shipbob). Any other order ShipBob holds is [released by ShipBob](https://docs.odus.com/docs/guides/order-management/order-statuses.md#held-by-shipbob) once the reason is dealt with in ShipBob. For a NextSmartShip order, Resubmit is offered only while the order is still whole — nothing shipped and no items cancelled: your provider still has every item it was originally sent, so resubmitting an order you have part-shipped would ship those units a second time, and one with cancelled items would ship the units you cancelled. Settle those with your provider directly instead. A correction to the customer does not resubmit those orders either. warning Resubmitting sends the details the order carries **now**. If you have not corrected the customer yet, the provider receives the same details it refused and holds the order again. ## Sending an address to ShipBob When ShipBob can't validate an order's shipping address, it holds the order with the reason **Invalid address**. ShipBob takes no second order, so you send it the address instead, for the shipments it holds. ShipBob checks the address again, and ships the order once it releases the hold. Correct the customer's shipping address first: open the customer from the order page. Within a few minutes, the corrected address is sent to ShipBob for every order of that customer it holds for its address, and each order's activity shows ShipBob's answer. To send the address an order already has — after ShipBob couldn't be reached, for example: 1. Click **Resubmit** in the hold notice. The dialog shows the address about to be sent. 2. Check the address: one ShipBob accepts is where it ships the order. If it isn't the address you expect, cancel, and correct the customer's shipping address or the address in ShipBob instead. 3. Confirm. Only the address is sent: ShipBob keeps the order it has. If ShipBob accepts the address, the order stays `On hold` until ShipBob releases the hold and ships it. The release shows here usually within an hour of ShipBob releasing the order. If ShipBob doesn't accept the address, or saves it for only part of the order, the dialog and the order's activity say so: correct the address in ShipBob. **Resubmit** isn't offered for such an order once you have cancelled some of its items, or recorded a shipment of it yourself: ShipBob still has those items in the order it holds, and would ship them once it releases the hold. Correct the address and remove those items in ShipBob instead. ## Next steps * [Cancel items](https://docs.odus.com/docs/guides/order-management/cancelling-orders.md) that will not ship. * [Export orders or bulk fulfill them](https://docs.odus.com/docs/guides/order-management/export-and-bulk-fulfill.md) with your logistics provider. --- # Order Statuses Every order and every order item carries a status derived from its quantities. Understanding how shipped and cancelled units interact helps you read the orders list correctly — especially for orders that were partially refunded or partially shipped. ## Order statuses | Status | Meaning | | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | `Unfulfilled` | No units have shipped yet. | | `Partially fulfilled` | Some units have shipped, but not all remaining units. | | `Fulfilled` | Every shippable item has all of its remaining (non-cancelled) units shipped. | | `Not fulfillable` | The order contains no shippable items, so there is nothing to ship — see [why an item is `Not Shippable`](#why-an-item-is-not-shippable). | | `Cancelled` | Every unit of every item was cancelled and nothing shipped. | ### Cancelled units reduce the target Cancelling units lowers the number of units an item needs to reach `Fulfilled` — it does not block the order from completing. An order counts as **fulfilled** when all of its *remaining* units have shipped, even if most units were cancelled along the way. An order is **cancelled** only in the strictest case: every unit of every item cancelled, and nothing ever shipped. Cancelling never moves money either way: a manual cancellation refunds nothing, so [refund the payment](https://docs.odus.com/docs/guides/payments/refunds.md) separately if the customer is owed one. ## Order item statuses Each item on the order detail page shows its own status, derived from its quantities: | Item status | Condition | | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Not Shippable` | The purchased product has no SKU, or its price is marked not fulfillable, so there is nothing for a warehouse to pick. It never affects the order status. | | `Unfulfilled` | Nothing shipped or cancelled yet. | | `Partial` | Some units shipped; some remain. | | `Fulfilled` | All units shipped; none cancelled. | | `Cancelled` | All units cancelled; none shipped. | | `Partially Cancelled` | Some units shipped, the rest cancelled; nothing remains. | ### Why an item is `Not Shippable` Whether an item ships is not decided on the order — it is decided by the product and the price it was bought at, captured at the moment the order was created: * A product with a SKU produces a shippable item, carrying that SKU and a unit count — unless the price it was bought at opts out, below. * A product without one — a warranty, shipping protection, a service fee — produces a `Not Shippable` item. It is on the order so you can see what the customer bought, but it has no quantities, is left out of exports and 3PL submissions, and cannot be fulfilled or cancelled. * A price marked `fulfillable: false` produces that same `Not Shippable` item even when its product does have a SKU. That is how an add-on sold under the same product as the goods is kept out of the warehouse. An order whose items are all `Not Shippable` is therefore `Not fulfillable`: there is nothing to ship, and it never reaches `Fulfilled`. If an item you meant to ship came out `Not Shippable`, either its product is missing a SKU or its price is marked `fulfillable: false`. Correcting either applies to every order created from then on; orders already created keep what was captured at the time. See [What the SKU decides](https://docs.odus.com/docs/guides/order-management/how-orders-are-created.md#what-the-sku-decides). ## How statuses change * Fulfillments increase an item's shipped count. * [Cancellations](https://docs.odus.com/docs/guides/order-management/cancelling-orders.md) — manual, or automatic after a void, full refund, or failed capture — increase its cancelled count. * The order status is recalculated immediately after either. ## Aged out orders `Aged out` marks an order that has still owed shipment for longer than you allow and whose payment was **captured**. It answers "what did we take money for and never ship?" Set the threshold under **Settings > General > Order management**, in the **Age out unfulfilled orders** field. The days are counted from the moment the payment was authorized, but only orders whose payment was also **captured** are ever marked — an authorization you never captured took no money, so it stays off this list. `0` disables the marking entirely (the default, so nothing changes until you turn it on). * It never changes the order status underneath. An order that is `Partially fulfilled` **and** aged out keeps saying which units still owe shipment — which is what you need to reconcile or refund it. The order detail page shows both, side by side. * In the orders list it is shown **instead of** the status: once an order has aged out, that is the thing worth reading off the row. Hover the chip to see the status it stands in for. * It never cancels anything. Money moved, so closing that loop stays a deliberate act: [cancel the items](https://docs.odus.com/docs/guides/order-management/cancelling-orders.md) or refund the payment. * It comes back off by itself. A shipment that arrives late, a cancellation that empties the order, or simply raising the threshold all clear the marking. ## On hold orders `On hold` marks an order your logistics provider is not shipping. It appears in four cases, and the order page says which: * **Your logistics provider is holding the order.** It accepted the order and then could not ship it: no carrier would take the shipment as addressed — a city that does not match its postal code, an incomplete name, a street the carrier does not know. See [Held by your provider](#held-by-your-provider). * **Your logistics provider is likely to hold the order.** The customer's details break one of its rules, so you can correct them before it sees them. See [Likely to be held by your provider](#likely-to-be-held-by-your-provider). * **Your logistics provider refused to fund the order from your prepaid balance.** Nothing on the order needs correcting: it is sent again once the balance covers it. See [Held for your prepaid balance](#held-for-your-prepaid-balance). * **ShipBob is holding the order.** Something stops ShipBob from shipping it — a payment method it couldn't charge, an address it can't validate, missing customs details. ShipBob ships the order once that is dealt with and it releases the hold: an address by correcting the customer here, anything else in ShipBob. See [Held by ShipBob](#held-by-shipbob). The first three apply to **NextSmartShip** orders, and the fourth to **ShipBob** orders. In each case: * It never changes the order status underneath. An order on hold is usually still `Unfulfilled` — nothing has shipped — but if you have shipped or cancelled part of it yourself, the order keeps that status and `On hold` is shown beside it. * It is shown **instead of** the status — in the orders list and on the order page — and ahead of `Aged out` when an order is both, because it is the one you can act on today. Open the order to see what to correct. * A cancelled order reads `Cancelled` instead, in the orders list and on the order page, even while your provider still holds it. Whether cancelling it here cancels it at your provider too is covered in [Orders already sent to your provider](https://docs.odus.com/docs/guides/order-management/cancelling-orders.md#orders-already-sent-to-your-provider). ### Held by your provider The order page says why the order is held and what to correct, and the marking appears within a few minutes of the provider raising it. What to do about it is a how-to: [Resubmitting an order your provider put on hold](https://docs.odus.com/docs/guides/order-management/managing-orders.md#resubmitting-an-order-your-provider-put-on-hold). Over the API, a held order carries the reason as `holdReasonCode`, one of a fixed set of codes, beside the provider's own words in `holdReason`. [Retrieve an order](https://docs.odus.com/docs/api/oms/order-controller-find-one) lists every code and what to correct for it. * Ship any part of a held order yourself, or cancel it, and the order page stops offering **Resubmit**. The marking stays beside the status of a part-shipped order until the provider answers, and a cancelled order reads `Cancelled` instead. * Correcting the customer resubmits the order for you. Within a few minutes of correcting what your provider receives for a customer — their name, phone number or shipping address — every order of theirs your provider is holding is resubmitted with the correction, as long as nothing on it has shipped or been cancelled. Six holds are left for you to resubmit, because a correction to the customer does not fix them: **Recipient tax ID required**, **Customs details missing**, **Restricted by the carrier**, **Carrier temporarily unavailable**, **No reason given** and **Held for another reason** — and so is an order [held for your prepaid balance](#held-for-your-prepaid-balance), which is sent again by itself once the balance covers it. * It stays `On hold` after it is resubmitted, until the provider answers. The order page says it is waiting. If the provider accepts the order, the marking clears; if it holds the order again, the new reason replaces the old one and you can resubmit again. * A correction you make while the order waits for that answer is sent for you if the provider holds the order again. If the provider accepts the order instead, it ships with the details it was sent before. * A hold you release in the provider's own dashboard clears here too, within the hour. * Nothing else is retried for you. A held order waits until you resubmit it or correct its customer, however long that takes — and so does one whose resubmission the provider refused. Each hold, each resubmission and each release is recorded in the order's activity, so you can read the sequence back. A resubmission made for you is listed as **Automatic**, after the customer update that caused it. ### Likely to be held by your provider If you have an active NextSmartShip integration, each order's customer details are checked against NextSmartShip's rules, listed in [Details NextSmartShip is likely to hold an order for](https://docs.odus.com/docs/guides/order-management/automatic-fulfillment.md#details-nextsmartship-is-likely-to-hold-an-order-for). The order page lists each detail at fault and what is wrong with it — for example, `City doesn’t match the postal code.` What to do about it is a how-to: [Correcting an order before your provider holds it](https://docs.odus.com/docs/guides/order-management/managing-orders.md#correcting-an-order-before-your-provider-holds-it). * Details are checked when the order is created, whenever you correct the customer before the order is sent, and each time the order is sent — automatically, or when you resubmit it. Until the order is sent, the marking clears as soon as a correction fixes every detail. Once it is sent, the marking describes what your provider was sent, so it stays until you resubmit the corrected details. Adding, activating or deactivating an integration does not re-check orders already created. * Once the order is sent, you don't have to wait for your provider to hold it: correct the customer and **Resubmit** it from the order page. Your provider accepts a correction only while it is holding the order; if it isn't yet, the order page says so, and you can resubmit once it is. A correction made before your provider holds the order is not resubmitted for you. * The marking itself never stops or delays an order: the order is submitted when its bundling window closes, as it would be without it. A missing required detail is listed among the problems and still stops the order, as [What each provider needs from an order](https://docs.odus.com/docs/guides/order-management/automatic-fulfillment.md#what-each-provider-needs-from-an-order) describes. Once you correct the customer, the order is sent again automatically. * It clears when the order is fulfilled or cancelled, when it goes to a provider other than NextSmartShip, and when no provider will receive it, for example because no fulfillment route matches it. ### Held for your prepaid balance NextSmartShip bills each order from a prepaid balance, and refuses to ship an order the balance can't cover. Such an order is `On hold` with the reason **Waiting for 3PL account funds**, and nothing on it needs correcting. Top up your balance with NextSmartShip, and the order is sent again automatically once the balance covers it, usually within an hour of the top-up. If the balance covers only some of your held orders, the others stay on hold until it covers them too. * To send the order sooner, for example right after you top up, **Resubmit** it from the order page, as you would any held order. The button is offered while NextSmartShip has the order; when NextSmartShip refused the order before creating it, there is nothing to resubmit, and the order waits for the automatic release. If your balance still can't cover it, it stays on hold. * Correcting the customer does not resubmit it for you, since no detail of the customer fills a balance. * Once you ship part of the order or cancel it yourself, the order page stops offering **Resubmit**. Like every hold, the marking stays beside the status of a part-shipped order, and a cancelled order reads `Cancelled` instead. * Over the API, the order has `holdStatus` `held` and `holdReasonCode` `insufficient_funds`. Resubmit it with `POST /orders/{orderId}/resubmit` in the [Order Management API](https://docs.odus.com/docs/api/oms/odus-oms-api) while `externalOrderId` is set; while it is empty, send it again with `POST /orders/{orderId}/retry-submission` instead. warning Such an order used to be marked `Waiting for funds` instead, with `tplSubmissionStatus` `awaiting_funds`. That value is no longer in the API reference. If your integration reads it, read `holdStatus` `held` with `holdReasonCode` `insufficient_funds` instead: a filter on `tplSubmissionStatus` `awaiting_funds` returns no orders, without an error. The order's activity records the hold and, once your balance covers the order, its release. ### Held by ShipBob ShipBob holds an order when something stops it from shipping it, and releases the hold itself once that is dealt with. Such an order is `On hold` here too, and the order page says what to do. The hold appears, and clears again, usually within an hour of ShipBob holding or releasing the order. | Why ShipBob holds the order | Order page | `holdReasonCode` | What to do | | --------------------------------------------------------------------------------------------------------------------------------- | ----------------------------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | It couldn't charge your payment method | **Payment method declined** | `insufficient_funds` | Update your payment method in ShipBob. ShipBob ships the order once a payment goes through. | | It can't validate the shipping address | **Invalid address** | `address_invalid` | Correct the customer's shipping address here. The correction is sent to ShipBob, which checks it again. **Resubmit** on the order page sends ShipBob the address the order already has. | | Customs details are missing | **Customs details missing** | `customs_details_invalid` | Add the customs details in ShipBob, such as a product's tariff code. | | Hazardous items can't be shipped abroad | **Restricted by the carrier** | `shipping_restricted` | Ask ShipBob how to ship the order. | | Anything else — a pick date not reached yet, a hold placed by hand, a packaging preference not set, an automatic processing pause | **Held for another reason** | `other` | Look the order up in ShipBob to see why it is held. | * ShipBob still has the order, so it is never sent to ShipBob a second time, and it can't be retried. The order page offers **Resubmit** only when ShipBob can't validate the address, and resubmitting sends ShipBob the address rather than the order. Don't create the order in ShipBob again yourself either, or it ships twice. * An order with part of it already shipped stays on hold, because ShipBob ships the rest once it releases the hold, and the order page shows its `Partially fulfilled` status beside the hold. * Correcting the customer here is how you fix an address ShipBob can't validate. Within a few minutes, the corrected name, phone number and shipping address are sent to ShipBob for each shipment of the order it is holding, and ShipBob checks the address again. The order stays `On hold` until ShipBob releases it. The order's activity records ShipBob's answer. The correction isn't sent if you have cancelled some of the order's items here, or recorded a shipment of it yourself: ShipBob still has those items in the order it holds, and would ship them once it releases the hold. Correct the address and remove those items in ShipBob instead. If ShipBob didn't accept the address for every shipment it holds, correct the address in ShipBob. If the correction couldn't be sent, or no answer appears within a few minutes, send the address the order has with **Resubmit**, as [Sending an address to ShipBob](https://docs.odus.com/docs/guides/order-management/managing-orders.md#sending-an-address-to-shipbob) describes, or correct it in ShipBob. * For any other hold, and for an order ShipBob isn't holding, a correction to the customer made here is not sent to ShipBob, which ships the order with the details it has. Correct them in ShipBob if they are wrong there too. * An order ShipBob holds for more than one reason is shown with one of them. Once ShipBob releases that one, the order page shows the next. * A hold ShipBob raises after you have fulfilled or cancelled the order here is not shown: the order owes nothing here, so it is no longer checked. A hold shown before that stays, beside the status, until ShipBob releases it. * Over the API, the order has `holdStatus` `held`, the `holdReasonCode` in the table, and ShipBob's own description of the hold in `holdReason`, such as `Invalid Address`. An order held for your payment method has the same code as an order [held for your prepaid balance](#held-for-your-prepaid-balance); its `holdReason` says it was your payment method. For an order held for `address_invalid`, `POST /orders/{orderId}/resubmit` in the [Order Management API](https://docs.odus.com/docs/api/oms/odus-oms-api) sends ShipBob the address the order has, as **Resubmit** on the order page does. Resubmitting any other order ShipBob holds is refused. warning An order held for your payment method used to be marked `Waiting for funds` instead, with `holdStatus` `awaiting_funds`. That value is no longer in the API reference. If your integration reads it, read `held` with `holdReasonCode` `insufficient_funds` instead: a filter on `holdStatus` `awaiting_funds` returns no orders, without an error. The order's activity records each hold, with ShipBob's reason, and its release. ## Shipped after cancellation `Shipped after cancellation` marks an order whose logistics provider shipped units you had already cancelled — for example, because a refund cancelled them after the order was sent. Not every cancellation reaches your provider, or reaches it in time (see [Orders already sent to your provider](https://docs.odus.com/docs/guides/order-management/cancelling-orders.md#orders-already-sent-to-your-provider)), so the provider can still ship what it was sent. Today this is detected for orders that **NextSmartShip** and **ShipBob** ship. * The cancelled units stay cancelled. They do not count as shipped, so the order keeps its status — usually `Cancelled` — and the order page shows the marking beside it. * Each such shipment is recorded in the order's activity with the units it carried, the carrier and the tracking number. Use them to recover the goods, or to ask the customer to pay for them. * In the orders list it is shown **instead of** the status, after `On hold` and ahead of `Aged out`. Hover the chip to see the status it stands in for. A cancelled or fulfilled order is not in the list's default view, so pick its status in the **Status** filter to find it. * It stays on the order. The goods have left the warehouse, whatever you do next. To prevent it, cancel the order at your provider while it has not shipped, whenever [it is not cancelled there for you](https://docs.odus.com/docs/guides/order-management/cancelling-orders.md#orders-already-sent-to-your-provider). Over the API, the order carries `cancelledUnitsShippedAt`, when the first such shipment was reported, and each shipment is a `cancelled_units_shipped` entry in `GET /orders/{orderId}/history`. To list every such order, call `GET /orders` with `cancelledUnitsShippedAt[ne]=null` in the [Order Management API](https://docs.odus.com/docs/api/oms/odus-oms-api). ## Statuses in the orders list **Aged out** and **On hold** are options in the **Status** filter, alongside the five statuses. The filter is multi-select and its options are alternatives: picking `Unfulfilled` and `Aged out` shows orders that are *either*, not only those that are both. By default the list shows `Unfulfilled`, `Partially fulfilled` and `Aged out` — everything that still needs action. A held order that still owes shipping is among them. One you have since fulfilled yourself leaves the default view with its status and keeps its hold marking; one you have cancelled leaves it too, and reads `Cancelled`. Pick `On hold` on its own to see every order your provider is holding, whatever the reason, including those you have cancelled, which read `Cancelled`. An order that is only likely to be held still owes shipping, so the default view includes it. Exports follow whatever the list is filtered to. --- # PayPal Shipment Tracking When you record a fulfillment with a tracking number for an order paid through PayPal, Order Management System automatically submits the shipment details to PayPal. You do not need to copy the tracking number into PayPal separately. ## Requirements Tracking is submitted only when all of the following are true: * The order was paid through **PayPal**. Tracking for orders paid through other processors is not submitted. * The fulfillment includes a **tracking number when it is first recorded**. Adding a tracking number to an existing fulfillment later does not queue it for PayPal submission. * The PayPal payment has been **captured**. note If you record the fulfillment before the payment is captured, Order Management System holds the tracking details until capture is confirmed. You do not need to record the fulfillment again. See [Captures](https://docs.odus.com/docs/guides/payments/capture.md) for more about captured and uncaptured payments. ## Timing and reliability Tracking submissions are processed in the background on a recurring schedule, so new tracking details may not appear in PayPal immediately. Temporary submission failures are retried automatically with increasing delays. If PayPal reports that the same tracking details already exist, Order Management System treats them as successfully submitted instead of creating a duplicate. ## Checking the outcome On the order page in the dashboard, each fulfillment's tracking link carries a small icon showing whether the tracking has been synced to the payment processor, is queued for the next attempt, is still held (for example, awaiting capture), or whether the sync failed. Hover over the icon to see when the details were submitted or why the sync failed. Fulfillments that were never queued — orders paid through other processors, or fulfillments recorded without a tracking number — show a faint info icon reading **No sync required**. Fulfillments recorded before tracking submission was introduced have no icon. ## Troubleshooting If tracking does not appear in PayPal: 1. If the fulfillment's tracking link on the order page has an icon next to it, hover over it. A held or failed sync explains itself there. 2. Confirm that the order was paid through PayPal. 3. Confirm that the tracking number was included when the fulfillment was first recorded, rather than added later. 4. Confirm that the payment has been captured. 5. Allow time for background processing and automatic retries. If the requirements are met and the tracking details still do not appear, contact ODUS support with the order and payment IDs. --- # Setting Up Order Management System (OMS) is a first-class addon. Enable it once from your dashboard and every successful payment that contains goods you ship becomes an order automatically — no manual webhook wiring or secrets to copy. Prerequisites * Order Management System is currently available in the **sandbox environment only**. * You need a working payments integration and at least one [product with a price](https://docs.odus.com/docs/guides/products/creating-products-and-prices.md). * Enabling an addon requires an **admin** (or owner) role on the account. ## 1. Enable the Order Management addon In the **Payments** view, go to **Settings > Addons** and click **Enable** on **Order Management**. Enabling does everything the integration needs in one step: * Provisions your Orders workspace for this account. * Subscribes Order Management System to the payment events it needs. * Configures the shared secret used to verify those events. When it finishes, an **Orders** option appears in the view switcher (top of the sidebar). Switch to the **Orders** view to configure the rest. note Disabling the addon later keeps all your Orders data — it only stops new payments from creating orders and hides the Orders view. Re-enabling resumes order creation from that point on; payments received while it was disabled are not backfilled. ## 2. Mark the products you ship A product tells Order Management System what to do when one of its prices is purchased. Only payments that contain at least one shippable product create orders. In the **Payments** view, open **Products** and, for every product you ship: * turn on **Shippable**, and * fill in the **SKU** the warehouse ships it under (add-ons that belong on the order but never ship, such as a warranty, are marked shippable with no SKU). See [What the SKU decides](https://docs.odus.com/docs/guides/order-management/how-orders-are-created.md#what-the-sku-decides) for what each setting changes. ## 3. Configure order bundling In the **Orders** view, go to **Settings > General**. The **Bundling window (minutes)** setting controls whether multiple purchases made by the same customer within a short time are grouped into a single order: * `0` — every payment creates its own order. * Any other value — follow-up purchases within the window are added to the existing order. See [How orders are created](https://docs.odus.com/docs/guides/order-management/how-orders-are-created.md#order-bundling) for the exact bundling rules. ## 4. Verify the setup 1. Make a test payment that includes a price of a shippable product. 2. In the **Orders** view, open **Orders**. The new order appears shortly after the payment succeeds, with one item per shippable line item. If no order appears, check that the payment succeeded, that the purchased product is marked **Shippable**, and that the Order Management addon is still enabled in **Settings > Addons**. If all three look right, [repair the addon](#repairing-the-addon). ## Repairing the addon Enabling creates two things on the payments side of your account, and both are ordinary objects you can see and edit yourself: * a **webhook subscriber** pointing at Order Management System, in **Developer tools > Webhooks**, and * a **secret key** noted `Order Management System`, in **Developer tools > Access keys**. If either is archived, deactivated, renamed or has its permissions changed, orders quietly stop being created — the addon can still show as **Enabled**. To put both back the way enabling created them, go to **Settings > Addons** and click **Repair** on **Order Management**. Repair is safe to run as often as you like. note Repair only touches those two objects and your account's Order Management setup. It does not delete or change any orders, and it does not backfill payments received while the addon was broken. Repair needs the same **admin** (or owner) role enabling does, because it manages API keys and webhooks on your behalf. If your account already has the maximum of five active webhook subscribers and Repair needs to create one, archive an unused subscriber in **Developer tools > Webhooks** first. ## Next steps * [Connect a logistics integration](https://docs.odus.com/docs/guides/order-management/integrations.md) to exchange files with your 3PL provider or enable automation. * [Set up automatic fulfillment](https://docs.odus.com/docs/guides/order-management/automatic-fulfillment.md) to submit orders to your provider automatically. --- # Payments Learn how to accept payments using ODUS with various payment gateways and methods. * [Flow Overview](https://docs.odus.com/docs/guides/payments/flow-overview.md) * [Making Payments](https://docs.odus.com/docs/guides/payments/making-payments.md) * [Payment Statuses](https://docs.odus.com/docs/guides/payments/payment-statuses.md) * [Manual Capture](https://docs.odus.com/docs/guides/payments/capture.md) * [Refunds and Voids](https://docs.odus.com/docs/guides/payments/refunds.md) * [Chargebacks](https://docs.odus.com/docs/guides/payments/chargebacks.md) * [Saved Payment Methods](https://docs.odus.com/docs/guides/payments/payment-methods.md) --- # Captures Manual capture separates authorization from the transfer of funds. When you authorize a payment with manual capture, the customer's card is held for the amount but no money is transferred until you explicitly capture it. ## Prerequisites * You have a Secret API Key. * You have read [Making Payments](https://docs.odus.com/docs/guides/payments/making-payments.md) and understand the standard payment flow. ## Capture methods ODUS supports three capture methods. You can set one per payment, or configure a default for your entire account. | Method | Behavior | | ------------------ | -------------------------------------------------------------------------------------------------- | | `automatic` | Funds are captured immediately when the payment is authorized. This is the default. | | `manual` | Funds are held but not transferred. You must explicitly call the capture endpoint. | | `automaticDelayed` | ODUS captures automatically after the number of days set in `captureDelay`. No API call is needed. | ## Step 1: Create a payment with manual capture Set `captureMethod` to `"manual"` in `paymentMethodOptions` when creating the payment with [`POST /v2/payments`](https://docs.odus.com/docs/api/core/v2/payment-controller-create). Creating a payment with manual capture ``` { "amount": 5000, "currency": "usd", "paymentMethodOptions": { "captureMethod": "manual", "card": { "mode": "cascade", "cascade": "cas_abc123" } } } ``` tip You can also set `captureMethod` at the payment method level (e.g. `paymentMethodOptions.card.captureMethod`). A payment method-level value overrides the top-level one for that specific payment method. ## Step 2: Authorize the payment Authorize the payment as usual — directly via the API with a saved payment method, or through the Checkout SDK. See [Making Payments](https://docs.odus.com/docs/guides/payments/making-payments.md#authorizing-a-payment) for details. After authorization, the payment will have: * `status: "succeeded"` * `isCaptured: false` * `displayStatus: "uncaptured"` The customer's card is held for the amount. No funds have been transferred yet. important Authorizations typically expire after approximately 7 days, though the exact limit depends on the gateway and card network. If you do not capture before the authorization lapses, the funds are released back to the customer automatically. ## Step 3: Capture the payment ### Via the API Send a [`POST /v2/payments/{id}/capture`](https://docs.odus.com/docs/api/core/v2/payment-controller-capture) request. The request body must be an empty JSON object. Capture request body ``` {} ``` The response is the full payment object. After a successful capture, `isCaptured` will be `true` and `capturedAt` will contain the capture timestamp. ### Via the Dashboard 1. Open the payment detail page. 2. Click **Actions** in the top-right area of the payment summary card. 3. Select **Capture** from the menu. 4. Confirm the dialog. warning Capturing a payment cannot be undone. You can issue a refund later if needed. ## Delayed automatic capture Use `captureMethod: "automaticDelayed"` together with `captureDelay` (number of days) if you want ODUS to capture automatically after a set period — without any additional API call. Creating a payment with delayed automatic capture ``` { "amount": 5000, "currency": "usd", "paymentMethodOptions": { "captureMethod": "automaticDelayed", "captureDelay": 2, "card": { "mode": "cascade", "cascade": "cas_abc123" } } } ``` The maximum `captureDelay` is **5 days** for card and Apple Pay. For PayPal, you can set up to **15 days** by specifying `captureDelay` at the PayPal level (`paymentMethodOptions.paypal.captureDelay`). warning Ensure `captureDelay` is within the authorization window of your gateway. If the authorization expires before the scheduled capture, the capture will fail. ### Capturing at night Delayed captures normally fire as soon as the delay elapses, which can be any time of day for the shopper. Enable **Capture at night** in [Merchant Configuration](https://docs.odus.com/docs/guides/dashboard/merchant-configuration.md#capture-at-night) to have ODUS schedule each delayed capture at a random moment between 02:00 and 04:00 in the shopper's local time instead. The scheduled capture is never moved earlier than `captureDelay`, so the delay you configure stays a lower bound. This only applies when the shopper's UTC offset is known. It is read from the shopper's browser at authorization — automatic for payments made through the Checkout SDK — and remembered for that shopper afterwards. Authorizations that carry no browser information of their own, such as server-to-server charges, subscription renewals, and retries, are scheduled from the offset last recorded for the same shopper. Payments for a shopper whose offset has never been recorded keep the standard schedule. Offsets are only recorded while **Capture at night** is enabled, so after turning it on a returning shopper's payments keep the standard schedule until their next authorization from a browser. note Shopper location is known only as the UTC offset reported at authorization, not as a full time zone. If a daylight-saving transition falls between authorization and capture, the effective window can shift by an hour. ## Voiding an uncaptured authorization To release the held funds before capturing, you can void the payment. See [Refunds and Voids](https://docs.odus.com/docs/guides/payments/refunds.md) for details. ## Setting a default capture method You do not need to set `captureMethod` on every payment. Configure a default for your account in Merchant Configuration — all payments without an explicit `captureMethod` will inherit this default. See [Merchant Configuration](https://docs.odus.com/docs/guides/dashboard/merchant-configuration.md) for setup instructions. --- # Chargebacks A chargeback occurs when a cardholder's bank reverses a payment and reclaims the funds on the customer's behalf. ODUS does not automatically detect chargebacks from your payment gateway — you must record them manually when your bank or processor notifies you. Once recorded, the payment is marked as chargebacked and you can optionally restrict the customer from making further payments. ## Prerequisites * A valid payment ID (`pay_...`) for the payment that received the chargeback. * A secret API key with the `payment:record_chargeback` permission, if recording via the API. ## Record a chargeback via the API Send a [`POST /v2/payments/{id}/chargebacks`](https://docs.odus.com/docs/api/core/v2/payment-controller-create-chargeback) request with the payment ID in the path. An empty body is valid when you only want to mark the payment as chargebacked without applying any customer restrictions: Minimal request ``` {} ``` warning Even with an empty body, the customer is blacklisted while [**Blacklist customer on chargeback**](https://docs.odus.com/docs/guides/dashboard/merchant-configuration.md#blacklist-customer-on-chargeback) is on, which it is by default. To record a chargeback without blacklisting the customer, turn that setting off first. After a successful request, the payment's `isChargebacked` field is set to `true` and `chargebackRecordedAt` is set to the current timestamp. note Recording a chargeback does not prevent further operations on the payment. You can still capture or reverse it after recording a chargeback. important If the charged-back payment settled a subscription — either its setup payment or a recurring renewal — ODUS cancels that subscription **immediately** when the chargeback is recorded. The subscription moves to `cancelled` and automatic billing is turned off. This happens for every recorded chargeback on a subscription payment, independently of the restriction options described below. Invoice statuses are not changed by the chargeback itself: an already-settled invoice stays `paid`, and any still-open invoice is left to its own payment lifecycle rather than being voided here. ### Blacklist the customer Blacklisting blocks all future payments by the customer based on their email address — including all products, recurring charges, and new customer-initiated payments. Blacklisting also cancels immediately every other active subscription of every customer with that email, not only the one tied to the charged-back payment. See [The Blacklist](https://docs.odus.com/docs/guides/customers/customer-restrictions.md#the-blacklist). By default, recording a chargeback blacklists the customer, however the chargeback is recorded. This is the [**Blacklist customer on chargeback**](https://docs.odus.com/docs/guides/dashboard/merchant-configuration.md#blacklist-customer-on-chargeback) setting in your merchant configuration, and it is on unless you turn it off. If you turn the setting off, set `blacklistCustomer` to `true` in the request to blacklist the customer for that chargeback: Record chargeback and blacklist customer ``` { "blacklistCustomer": true } ``` warning While **Blacklist customer on chargeback** is on, `blacklistCustomer` is ignored. Sending `false` does not stop the customer from being blacklisted, and the request does not fail. ### Apply fine-grained restrictions Use the `customerRestrictions` object for more targeted control over what the customer can and cannot do after the chargeback. Set `restrictCit` to block customer-initiated payments, `restrictMit` to block merchant-initiated payments, and `restrictReason` to record a human-readable explanation. See the [`POST /v2/payments/{id}/chargebacks`](https://docs.odus.com/docs/api/core/v2/payment-controller-create-chargeback) reference for the full parameter list. Record chargeback with fine-grained restrictions ``` { "customerRestrictions": { "restrictMit": true, "restrictCit": false, "restrictReason": "Chargeback filed by customer on 2024-03-15" } } ``` You can combine `blacklistCustomer` and `customerRestrictions` in the same request. important `blacklistCustomer` and `customerRestrictions` are independent mechanisms. Blacklisting is based on the customer's email and blocks payments across the platform. Customer restrictions are scoped to your merchant account and offer finer-grained control. ### Response The payment object in the response includes: * `isChargebacked: true` * `chargebackRecordedAt`: the timestamp when the chargeback was recorded. If restrictions were applied, the `customer` object embedded in the payment will also reflect the updated `restrictMit`, `restrictCit`, and `isRestricted` fields. tip Restrictions can also be managed independently of a chargeback using the [`POST /v2/customers/{id}/restrictions`](https://docs.odus.com/docs/api/core/v2/customer-controller-manage-restrictions) endpoint. This is useful if you need to update or remove restrictions at a later time. ## Record a chargeback via the Dashboard 1. In the Dashboard, go to **Payments**. 2. Find and open the payment that received the chargeback. 3. Click **Record chargeback**. 4. In the dialog, configure the available options: * **Restrict Recurring Payments** — blocks the customer's future recurring (merchant-initiated) charges, so any of their *other* active subscriptions stop renewing and lapse at the end of their current billing period. This is separate from the automatic cancellation above: the subscription tied to the charged-back payment is always cancelled immediately, regardless of this option. * **Restrict Customer Initiated Payments** — blocks future customer-initiated payments, such as upsells and one-off purchases. * **Restrict reason** — an optional note stored on the customer record. 5. Click **Submit**. The dialog does not offer to blacklist the customer. While [**Blacklist customer on chargeback**](https://docs.odus.com/docs/guides/dashboard/merchant-configuration.md#blacklist-customer-on-chargeback) is on, which it is by default, every customer whose chargeback you record is blacklisted. note The **Record chargeback** action is only available for payments that have not already been chargebacked. ## View chargebacked payments In the **Payments** list, use the display status filter and select **Chargeback** to show only payments with a recorded chargeback. --- # Error Codes When a payment fails, ODUS reports the reason as a standardized error code. Where the failure came from a payment processor, the processor-specific error is normalized into that same set — so regardless of whether the payment was routed through Stripe, Adyen, PayPal, or any other gateway, the error response uses the same codes. This lets you build a single error-handling path in your integration instead of mapping each processor's errors individually. Error information is returned in the `outcome` object on the transaction. note These are **declines** — a request the API processed successfully, reporting a payment that did not go through. Usually the issuer refused it. Some codes instead report a payment that was never authorized against the payment method and cannot be by trying again; those carry `declineType` `not_attempted`. For requests the API refuses to process at all (validation failures, wrong resource state, missing permissions), see [Error Responses](https://docs.odus.com/docs/guides/api-features/errors.md). ## The outcome object When a transaction fails, the response includes an `outcome` object with the following fields: | Field | Type | Visibility | Description | | ------------------ | ---------------- | ---------- | --------------------------------------------------------------------------------------------------------------- | | `status` | `string` | Public | Operation result: `success`, `partial`, or `failure`. | | `errorCode` | `string` | Sensitive | Standardized error code from the table below. | | `errorMessage` | `string` | Sensitive | Detailed error message. May contain sensitive information (e.g. fraud). | | `safeErrorMessage` | `string` | Public | Customer-safe error message that can be displayed in your UI. | | `declineType` | `string` | Sensitive | Either `soft_decline`, `hard_decline` or `not_attempted`. | | `retryStrategy` | `string` | Public | Recommended retry strategy for the client. | | `processorCode` | `string` | Sensitive | Raw error code returned by the payment processor. | | `processorMessage` | `string` | Sensitive | Raw error message returned by the payment processor. | | `specialCode` | `string \| null` | Public | Special status codes such as `confirmation_requested` for 3DS redirects. `null` when no special status applies. | warning **Sensitive** fields are only visible when using a secret API key. They are not returned to a checkout key, which is what the Checkout SDK uses. Never expose sensitive fields to the customer — use `safeErrorMessage` instead of `errorMessage`. ## Decline types Every error code carries a decline type: whether the payment method was refused, and whether repeating the same attempt could work. | Type | Meaning | | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `soft_decline` | The payment may succeed if retried. The issue is potentially temporary or resolvable by the customer. | | `hard_decline` | The payment method was refused and will not succeed on retry — it is blocked, restricted, or revoked. | | `not_attempted` | This payment was not authorized against the payment method, so the issuer refused nothing. The cause lies elsewhere — the stored payment method, your account, the request, the order, a policy rule, or a capture that already happened. Read `errorCode` for which, and the remedy. Repeating the same operation unchanged cannot succeed. | ## Error code reference The table below lists every standardized error code, grouped by decline type. Moving from v1 Eight codes and the `not_attempted` decline type are new in v2. v1 reports each of those payments as a broader code with a different decline type, and for three of them a different retry strategy. If your integration branches on `errorCode` or `declineType`, see [Codes added in v2](#codes-added-in-v2). ### Hard declines These are non-recoverable. Do not retry the same payment method. | Code | Description | Retry Strategy | Safe Message | | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | -------------- | ---------------------------------------------------------------- | | `fraud_suspected` | The processor flagged the transaction as potentially fraudulent. | `never_retry` | The payment was declined. Please use a different payment method. | | `card_restricted` | The payment method or account is restricted — for a card, lost, stolen, or blocked by the issuer. | `never_retry` | The payment was declined. | | `generic_hard_decline` | Authorization was revoked or another non-recoverable decline occurred. | `never_retry` | The payment was declined. | | `authentication_rejected` | The issuer's authentication server refused the authentication outright, or the card has already used up its allowed challenges. | `never_retry` | The payment was declined. Please use a different payment method. | ### Soft declines — payment method issues The customer can resolve these by correcting or changing their payment method. | Code | Description | Retry Strategy | Safe Message | | --------------------------- | --------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | -------------------------------------------------------------------------------------------- | | `insufficient_funds` | The payment method does not have enough funds to cover the transaction. | `retry_after_delay` | There aren't enough funds available for this payment. Please use a different payment method. | | `invalid_card_details` | The payment details were rejected as invalid — for a card, the number, security code or expiry. | `retry_with_new_payment_method` | Some of the payment details are incorrect. Please check them and try again. | | `expired_card` | The payment method is past its expiry date. | `retry_with_new_payment_method` | This payment method has expired. Please use a different one. | | `authentication_failed` | Authentication was required but could not be completed — a wrong one-time code, or a card the issuer will not authenticate. | `retry_with_new_payment_method` | The payment couldn't be verified. Please try again or use a different payment method. | | `transaction_not_permitted` | The payment method or account does not allow this transaction type. | `retry_with_new_payment_method` | The payment was declined. Please use a different payment method. | ### Soft declines — processor / issuer errors These are transient. Retrying may succeed without any customer action. | Code | Description | Retry Strategy | Safe Message | | ---------------------------- | ---------------------------------------------------------------------------------------------- | ------------------- | --------------------------------------------------------------------------------------------------- | | `generic_issuer_error` | The issuer or funding provider returned a generic error. Typically temporary. | `retry_immediately` | We couldn't confirm this payment in time. Please check whether it went through before trying again. | | `generic_processor_error` | The payment processor returned a generic error. | `retry_immediately` | We couldn't confirm this payment in time. Please check whether it went through before trying again. | | `processor_rate_limited` | The processor is rate-limiting requests. | `retry_immediately` | We're busy right now. Please try again in a few moments. | | `processor_timeout` | The processor did not respond in time. | `retry_immediately` | We couldn't confirm this payment in time. Please check whether it went through before trying again. | | `authentication_unavailable` | Authentication never ran — the issuer's authentication server timed out or failed technically. | `retry_immediately` | Something went wrong. Please try again. | | `authentication_required` | The issuer requires the payment to be authenticated and refused it without 3D Secure. | `retry_immediately` | This payment needs to be verified. Please try again to continue. | info `authentication_cancelled`, `authentication_rejected` and `authentication_unavailable` each describe a specific way a 3D Secure authentication did not succeed. A processor may report all of them under a single refusal reason — Adyen's "3D Not Authenticated" is one — so ODUS separates them from the authentication result on the response, because the right response differs: a customer who dismissed the challenge stopped the payment themselves and can start it again, an authentication server that never answered can be retried without involving them, and an issuer that refused outright must not be retried at all. `authentication_cancelled` is listed under [Soft declines — other](#soft-declines--other), alongside `cancelled`, because the two describe the same customer action. `authentication_required` is the one case where authentication has not happened yet rather than failed. The issuer refused an exemption and wants the payment authenticated, so retrying it with 3D Secure is the documented remedy — there is nothing wrong with the payment method, and asking the customer to change it will not help. `authentication_failed` is the general case, and is not limited to 3D Secure: it also covers a card the issuer will not authenticate at all, and approval failures on other payment methods such as a PayPal billing agreement. ### Soft declines — other | Code | Description | Retry Strategy | Safe Message | | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------- | --------------------------------------------------------------------------------- | | `generic_decline` | A general decline with no specific reason from the processor. | `retry_after_delay` | The payment was declined. Please try again or use a different payment method. | | `cancelled` | The customer cancelled the payment, at a 3DS challenge or at another approval step such as a PayPal approval. Where the processor identifies a cancelled 3DS challenge specifically, `authentication_cancelled` is reported instead. | `retry_after_delay` | The payment was cancelled. You can try again when you're ready. | | `authentication_cancelled` | The customer dismissed the authentication challenge. Reported instead of `cancelled` where the processor identifies a cancelled 3DS challenge specifically; both describe the same customer action and carry the same strategy. | `retry_after_delay` | The payment was cancelled. You can try again when you're ready. | | `not_supported` | The payment method or currency is not supported by the processor. | `retry_after_delay` | This payment method isn't available for this payment. Please use a different one. | | `internal_error` | An error occurred within ODUS while processing the payment. | `retry_after_delay` | Something went wrong. Please try again. | ## Codes added in v2 Some of the codes above cover more than one outcome, and the advice attached to them cannot be true of all of them at once. `generic_hard_decline` reports a payment that has *already been captured* alongside genuine refusals, so "use a different payment method" invites a second charge for the same order. `card_restricted` reports a problem with the store's own processor account alongside a blocked card, so no payment method your customer chooses gets past it. And `generic_processor_error` and `generic_issuer_error` mean the payment was left unresolved and needs reconciling before a retry — but they also carry requests that were refused outright, where nothing is unresolved at all. v2 gives each of those outcomes its own code, and adds two more — `payment_method_unusable`, for a stored payment method that can no longer be charged, and `stored_credential_revoked`, for one the processor now refuses to let you charge. **v1 is unchanged**: it keeps reporting all eight as the broader code in the last column, with that code's decline type and retry strategy. All eight are `not_attempted` with `never_retry`. None is a decision by the issuer about your customer's payment method, and none is resolved by another attempt at the same thing. For all but `payment_method_unusable` and `stored_credential_revoked`, the remedy is in the store's hands rather than the customer's. | Code (v2) | Description | Safe Message | Reported in v1 as | | ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | --------------------------- | | `payment_method_unusable` | The stored payment method can no longer be charged and has to be replaced. Ask the customer for a new one; retrying this one cannot succeed. | The payment was declined. Please use a different payment method. | `generic_hard_decline` | | `stored_credential_revoked` | Permission to charge a stored payment method has been withdrawn, so the processor refuses it without asking the funding source. Ask the customer to authorize a new payment method; this one cannot be charged again. | The payment was declined. Please use a different payment method. | `generic_hard_decline` | | `already_captured` | The payment has already been taken. Nothing is owed, and charging again would take it twice. | This payment has already gone through. You don't need to pay again. | `generic_hard_decline` | | `merchant_account_restricted` | Your account with the processor is locked, closed or restricted, so it cannot accept payments. | This store can't take this payment right now. | `card_restricted` | | `merchant_configuration_error` | Your account is not set up to accept this payment — the payment method, country or currency is not enabled on it. | This store can't take this payment right now. | `generic_issuer_error` | | `invalid_request` | The processor refused the request as malformed or invalid. It never reached an authorization, so nothing is unresolved. | This payment can't be completed. | `generic_processor_error` | | `blocked_by_policy` | A risk or compliance rule blocked the payment. It is not a fraud signal about your customer's payment method. | The payment was declined. | `fraud_suspected` | | `order_not_permitted` | Something about the order is not accepted — where it ships to, what it contains, or how its amount is expressed. | This payment can't be completed. | `transaction_not_permitted` | info `invalid_request` and `merchant_configuration_error` are the definitive counterparts to `generic_processor_error` and `generic_issuer_error`. Where those two warn you that the payment may have gone through and needs checking before a retry, these two tell you it did not: the request was refused before any money moved, so you can act on the failure immediately instead of reconciling first. ## Retry strategies Each error code maps to a recommended retry strategy. Use this to decide how your integration should respond to a failure. | Strategy | Meaning | | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `never_retry` | Do not repeat this operation unchanged — it cannot succeed. Check `errorCode` for the remedy: a `hard_decline` means the payment method is blocked, restricted or flagged for fraud and retrying it may incur network penalties, while a `not_attempted` code points at the stored method, your account, or the request instead. | | `retry_with_new_payment_method` | The current payment method cannot be used. Ask the customer to provide a different one. | | `retry_after_delay` | The issue may resolve on its own (e.g. insufficient funds). Wait before retrying. | | `retry_immediately` | The failure is transient and no decision was reached. Your integration may retry at once **only** after `authentication_required`, `authentication_unavailable` or `processor_rate_limited`, where the request is known to have been turned away before authorisation — for `authentication_required`, retry with 3D Secure rather than unchanged. The other codes carrying this strategy — `processor_timeout`, `generic_processor_error`, `generic_issuer_error` — cover cases the processor never resolved, so the payment may already have been processed: check its current status and reconcile before retrying, or your customer can be charged twice. | tip For subscription payments, ODUS uses the retry strategy automatically when scheduling retries. When a subscription renewal fails with `authentication_unavailable` or `processor_rate_limited`, ODUS also resends it unchanged within minutes, before your retry policy starts. See the [retry policy guide](https://docs.odus.com/docs/guides/subscriptions/retry-policy.md#payments-that-never-reached-a-decision) for details. ## Example response A failed authorization returns the `outcome` on the latest transaction: Failed payment response (secret key) ``` { "id": "pay_a1b2c3", "status": "open", "displayStatus": "failed", "latestTransaction": { "outcome": { "status": "failure", "errorCode": "insufficient_funds", "errorMessage": "The payment method has insufficient funds.", "safeErrorMessage": "There aren't enough funds available for this payment. Please use a different payment method.", "declineType": "soft_decline", "retryStrategy": "retry_after_delay", "processorCode": "card_declined", "processorMessage": "Your card has insufficient funds.", "specialCode": null } } } ``` info When read with a checkout key, the response only includes `status`, `safeErrorMessage`, `retryStrategy`, and `specialCode`. Sensitive fields like `errorCode`, `errorMessage`, `declineType`, `processorCode`, and `processorMessage` are omitted. ## Related * [Payment Statuses](https://docs.odus.com/docs/guides/payments/payment-statuses.md) — lifecycle states and boolean flags on the payment object. * [Subscription Retry Policy](https://docs.odus.com/docs/guides/subscriptions/retry-policy.md) — how ODUS automatically retries failed subscription payments based on the retry strategy. --- # Flow Overview Learn about how payment flows work in ODUS and the different participants involved in the process. Your application needs to be ready to handle these flows to ensure a smooth payment experience for your customers. There are two main payment flows that must be integrated into your application: the **Basic Flow** and the **Redirect Flow**. Both flows involve multiple participants: 1. **Customer**: The end user making the payment. 2. **Merchant Frontend**: The merchant's web application where the customer initiates the payment. 3. **ODUS Checkout SDK**: The SDK that handles secure payment processing. 4. **Merchant Backend**: The merchant's server that communicates with the ODUS API. 5. **ODUS API**: The backend service that processes payments and interacts with the Payment Service Providers (PSPs). 6. **Payment Service Provider (PSP)**: The third-party service that processes the payment on behalf of the merchant. ## Basic Flow This flow covers card payments without 3DS authentication. It includes the following steps: ## Redirect Flow Redirect flow is used for payments that require the customer to be redirected to a third-party page, such as for 3DS authentication or PayPal payments. warning Unlike the basic payment flow, the Checkout SDK won't handle the payment result for redirect flows. The customer is redirected to the merchant's return URL, and the merchant backend must verify the final payment status by calling the ODUS API. The SDK **will not** emit `onPaymentSucceeded` or `onPaymentFailed` events in this case. You can read more about it in the [Redirect Flow Guide](https://docs.odus.com/docs/guides/checkout/3ds-and-redirect-handling.md). ### 3DS Authentication Flow This flow covers card payments that require 3DS authentication, which involves redirecting the customer to a secure page for authentication provided by the Payment Service Provider (PSP). The flow includes the following steps: ### PayPal Flow --- # Making Payments Learn how to create payments for usage with Checkout SDK or direct API calls. Based on [the Payment Flow Overview](https://docs.odus.com/docs/guides/payments/flow-overview.md), the first step is to create a payment. This operation is strictly backend-to-backend and meant to be performed by the merchant's backend server using a Secret API Key. ## Creating a Payment important Creating a payment **does not** charge the customer or reserve any funds, the payment stays in the `open` state until it is authorized. We encourage you to create a payment early in the checkout flow, for example when the customer opens the checkout page or opens up the offer details. This way you can ensure that the payment is ready for authorization when the customer is ready to pay. The process of creating a payment is the first step in the payment flow, in which you configure the payment details, such as the amount, currency, and payment method options. It is mandatory that a payment is first created before it can be authorized. To create a payment, you need to send a [`POST /v2/payments`](https://docs.odus.com/docs/api/core/v2/payment-controller-create) request. Many of the fields are optional and can be omitted based on your use-case. Any payment, however, requires the following: 1. `amount`: Payment amount in minor currency, for example 1000 for 10.00 USD. Must be either provided directly in the request body or derived from the `payment.cart.items` array. 2. `currency`: The currency of the payment, for example `usd` or `eur`. 3. `initiator`: Indicates whether the payment is initiated by the merchant or the customer, defaults to `customer`. Note that merchant-initiated payments will fail immediately if any customer action, such as 3DS authentication, is required. 4. `paymentMethodOptions`: An object containing the payment method options. This is required if you want to specify a particular Gateway Profile, Cascade or [Route](https://docs.odus.com/docs/guides/routing/routing-card-payments.md) to use for the payment. If not specified, the defaults from the Merchant Configuration will be used, if no defaults are set, the payment won't be created. tip If a payment method (such as `card` or `paypal`) is not present in `paymentMethodOptions` and there's no defaults in the Merchant Configuration, the payment will still be created, but the missing payment method will not be available for payment authorization. By default, the payment will be created with no customer or payment method, expecting them to be provided during authorization at a later step. In many cases, however, you will want to make the payment with a saved payment method, for example for a returning customer. In this case, you can provide the following optional fields: 1. `paymentMethod`: The ID of the payment method to be used for the payment. Can only be provided together with the customer who this payment method belongs to. 2. `customer`: The ID of the customer who is making the payment. ### Example request bodies The most common scenario is creating a payment using pre-configured ODUS Prices. They should be passed to the `cart.items` array and provide the IDs of said prices. Note that the `amount` field is not required in this case, as it will be derived from the prices in the cart. You can also optionally overwrite the `amount` for each individual item in the cart. Creating a payment with cart ``` { "currency": "usd", "initiator": "customer", "paymentMethodOptions": { "card": { "mode": "cascade", "cascade": "cas_xyz123" } }, "cart": { "items": [ { "price": "price_abc321", "quantity": 1 }, { "price": "price_def654", "quantity": 1, "amount": 1999 } ] } } ``` Providing multiple payment methods is just as simple, you just need to specify the `gatewayProfile` or `cascade` in the `paymentMethodOptions` for each payment method you want to use. If you don't specify a payment method, the defaults from the Merchant Configuration will be used. Creating a payment with multiple payment methods ``` { "amount": 1000, "currency": "eur", "initiator": "customer", "paymentMethodOptions": { "card": { "mode": "cascade", "cascade": "cas_abc123" }, "paypal": { "mode": "direct", "gatewayProfile": "gwp_xyz456" } } } ``` To create a payment with a saved payment method, you need to provide the `customer` and `paymentMethod` fields. The payment method must belong to the customer, otherwise the request will fail. Creating a payment with a saved payment method ``` { "amount": 5000, "currency": "usd", "initiator": "customer", "paymentMethodOptions": { "card": { "mode": "cascade", "cascade": "cas_abc123" } }, "customer": "cus_123456", "paymentMethod": "pm_789012" } ``` A customer-initiated payment can also be created with just two parameters: `amount` and `currency`, the rest will be derived from the Merchant Configuration. This is useful for one-off payments or when you don't want to integrate with ODUS Prices. Creating a payment with just amount and currency ``` { "amount": 1500, "currency": "eur" } ``` ## Authorizing a Payment After successfully creating a payment, the next step is to authorize it by sending a [`POST /v2/payments/{paymentId}/authorize`](https://docs.odus.com/docs/api/core/v2/payment-controller-authorize) request. This is where the actual payment is processed, and the customer is charged or funds are reserved. There are several ways to authorize a payment, depending on whether you want to use a saved payment method or require the customer to enter their payment details, they are: 1. **Directly using the API**: Can only be used when using a saved payment method and is the preferred way to pay for returning customers. Note that even though such payments normally do not require any customer interaction, it is possible that the customer's bank requests 3DS authentication. Such payments will be marked as `requires_action` and you can either complete the 3DS by redirecting customer to the `payment.action.redirectUrl` or consider the payment failed. 2. **Using the Checkout SDK**: This is the only way to authorize a payment when the customer needs to enter their payment details, for example when paying with a new card or PayPal. The Checkout SDK will handle the customer interaction and 3DS authentication if required. note Authorizing a payment with raw card details is not allowed. You must use the Checkout SDK which will securely collect the card details leaving you out of PCI-DSS compliance scope. If you need to authorize a payment with a saved card, you can use the API directly as described above. ### Authorizing using the API To authorize a payment via the API, first you need to ensure that it was created with a saved payment method as described above. Then you can send a [`POST /v2/payments/{paymentId}/authorize`](https://docs.odus.com/docs/api/core/v2/payment-controller-authorize) request. Keep in mind that there's a slight distinction between authorizing a merchant-initiated payment and a customer-initiated payment For all customer-initiated payments, you must provide a valid `context.returnUrl`, which will be used to redirect the customer after they complete the redirect flow. As mentioned above, you may choose to not redirect the customer at all, in which case you can provide any valid URL, such as your website's homepage. For merchant-initiated payments, the `context` is ignored and the payment will automatically fail if any customer interaction is required. Assuming we created a payment with a saved payment method, the request body for authorizing it would look like this: Authorizing a payment with a saved payment method ``` { "context": { "returnUrl": "https://example.com/return" } } ``` If the payment does not go through, the request answers `402 Payment Required` with `type` `payment_error`. A `400` means the request itself was not accepted. To find out why the payment failed and whether to try again, read `outcome.errorCode` and `outcome.retryStrategy` on the payment's latest transaction. See [Error Responses](https://docs.odus.com/docs/guides/api-features/errors.md#payment_error-is-not-your-fault-either) and [Error Codes](https://docs.odus.com/docs/guides/payments/error-codes.md). ### Authorizing using the Checkout SDK warning Redirect flows such as 3DS Authentication or PayPal require special handling by your application for optimal user experience. You can read more about it in [the Handling Redirects guide](https://docs.odus.com/docs/guides/checkout/3ds-and-redirect-handling.md). To authorize a payment when the customer needs to enter their payment details (new card, PayPal, etc.), you must use the Checkout SDK. The SDK securely collects payment information and handles 3DS authentication automatically. For complete setup instructions, code examples, and configuration options, refer to the [Checkout SDK Getting Started guide](https://docs.odus.com/docs/guides/checkout/drop-in/getting-started.md). --- # Working with Saved Payment Methods A saved payment method is a tokenized card, PayPal account, or Apple Pay wallet stored against a customer. Once saved, it can be reused for future payments and subscription renewals without requiring the customer to enter their details again. ## How payment methods are saved Payment methods are created automatically when a customer completes a payment through the Checkout SDK. There is no standalone endpoint to save a card or PayPal account in isolation. The customer's payment details are tokenized and stored securely during the authorization step. After authorization, ODUS associates the new payment method with the customer. You can then retrieve its ID and use it for future payments. ## Listing payment methods for a customer To retrieve all payment methods for a specific customer, send a [`GET /v2/payment-methods`](https://docs.odus.com/docs/api/core/v2/payment-method-controller-find-all-paginated) request with the `customer[eq]` filter: ``` GET /v2/payment-methods?customer[eq]=cus_2R0uWz4tu9wxTMBXxj9CJfSE9vV ``` The customer object does not include its payment methods, so this list is where you read them. The response contains one page of payment methods: Response ``` { "data": [ { "id": "pm_2RTUw7lL6IuBUwGQOD1EB0OAmx7", "type": "card", "customer": "cus_2R0uWz4tu9wxTMBXxj9CJfSE9vV", "isDefault": true, "card": { "brand": "visa", "lastFour": "4242", "expMonth": "12", "expYear": "2027", "cardholderName": "Jane Doe" }, "createdAt": "2024-02-18T12:00:00Z", "updatedAt": "2024-02-21T12:15:00Z" } ], "pagination": { "mode": "cursor", "pageSize": 10, "hasNextPage": false, "hasPreviousPage": false, "nextCursor": null, "prevCursor": null } } ``` A customer with more payment methods than one page holds has `hasNextPage: true`. Send `nextCursor` back as `cursor` to read the next page. See [Pagination](https://docs.odus.com/docs/guides/api-features/pagination-filtering-sorting.md#pagination). To retrieve a single payment method by its ID, use [`GET /v2/payment-methods/{id}`](https://docs.odus.com/docs/api/core/v2/payment-method-controller-find-one). tip To find only the default payment method for a customer, add `isDefault[eq]=true` to the request: `GET /v2/payment-methods?customer[eq]=cus_xxx&isDefault[eq]=true` See the [`GET /v2/payment-methods`](https://docs.odus.com/docs/api/core/v2/payment-method-controller-find-all-paginated) reference for the full list of response fields. ## Using a saved payment method for a payment To charge a customer with a saved payment method, provide the `customer` and `paymentMethod` IDs when creating a payment. The payment method must belong to the specified customer. Creating a payment with a saved payment method ``` { "amount": 5000, "currency": "usd", "initiator": "customer", "paymentMethodOptions": { "card": { "mode": "cascade", "cascade": "cas_abc123" } }, "customer": "cus_2R0uWz4tu9wxTMBXxj9CJfSE9vV", "paymentMethod": "pm_2RTUw7lL6IuBUwGQOD1EB0OAmx7" } ``` For more details on creating and authorizing payments, see [Making Payments](https://docs.odus.com/docs/guides/payments/making-payments.md). ## Payment methods and subscriptions When a subscription is created for a customer, ODUS uses the customer's default payment method at that point in time for all recurring charges. If the default payment method changes after the subscription is created, existing subscriptions are not affected — they continue to use the payment method that was active when they were set up. warning If the payment method used by a subscription expires, recurring charges will fail. Make sure customers keep their payment methods up to date. ## Viewing payment methods in the Dashboard Open the **Customers** page and select a customer. The customer detail page includes a **Payment methods** section showing all saved payment methods for that customer. Each row shows: * The cardholder name (or customer name for non-card methods) * The payment method type and last four digits * The expiration date (cards only) * Whether the method is the customer's default --- # Payment Statuses A payment in ODUS carries two separate status fields: `status` and `displayStatus`. Understanding the difference between them — and how they relate to the boolean lifecycle flags — helps you build integrations that correctly handle every payment outcome. ## The `status` field `status` represents the core lifecycle of a payment. It has four possible values: | Value | Description | | ----------------- | ------------------------------------------------------------------------------------------------------------------------- | | `open` | The payment has been created and is waiting to be processed. This is the initial state for every payment. | | `requires_action` | The customer must complete an action before processing can continue, typically a 3DS redirect. | | `succeeded` | The payment has been completed successfully. Funds have been authorized (and captured, if capture method is `automatic`). | | `cancelled` | The payment has been permanently stopped. No further processing will occur. | ### Terminal vs. non-terminal `open` and `requires_action` are **non-terminal** — the payment can move forward from these states. `cancelled` is **terminal** — once cancelled, a payment cannot change. `succeeded` is the normal end state; in the narrow case where an uncaptured authorization is voided, it may also transition to `cancelled`. ### State transitions **What causes each transition:** * **`open` → `requires_action`** — The PSP requires the customer to complete an authentication step, such as 3DS. The payment object receives an `action.redirectUrl` pointing to the authentication page. * **`requires_action` → `open`** — The action was not completed within 24 hours. The payment is reset to `open` so that a new attempt can be made. * **`open` / `requires_action` → `succeeded`** — The PSP confirms the payment is authorized (and captured, for automatic capture). * **`open` / `requires_action` / `succeeded` → `cancelled`** — The payment is cancelled programmatically or by the merchant via the Dashboard. This is also the outcome when all retry attempts are exhausted. ## The `displayStatus` field `displayStatus` is a derived, user-friendly value computed from `status` and the boolean lifecycle flags below. It is useful for displaying a meaningful label to your team or customers, but you should not use it in webhook or API logic. Use `status` and the individual flags for that. | Value | Meaning | | -------------------- | ------------------------------------------------------------------------------ | | `unattempted` | Payment is `open` and no transaction has been attempted yet. | | `incomplete` | Payment is in `requires_action` — waiting for customer to complete a redirect. | | `failed` | Payment is `open` but a previous attempt failed. Can still be retried. | | `retrying` | Payment is actively cycling through retry attempts after a failure. | | `succeeded` | Payment succeeded and has been fully captured. | | `uncaptured` | Payment succeeded but capture has not yet occurred (manual capture mode). | | `partially_reversed` | Payment succeeded and has been partially refunded. | | `reversed` | Payment succeeded and has been fully refunded or voided. | | `chargeback` | A chargeback has been recorded against this payment. | | `cancelled` | Payment is cancelled. | ## Boolean lifecycle flags The `status` field alone does not tell you whether a payment has been captured, refunded, or chargebacked. These outcomes are tracked by separate boolean fields returned alongside `status`. | Field | Type | Meaning | | ----------------- | ------- | ------------------------------------------------------------------------- | | `isCaptured` | boolean | Funds have been captured from the customer. | | `isReversed` | boolean | The payment has been at least partially refunded or voided. | | `isFullyReversed` | boolean | The entire authorized or captured amount has been reversed. | | `isChargebacked` | boolean | A chargeback has been recorded for this payment. | | `isRetrying` | boolean | The payment is currently in a retry cycle after a failed attempt. | | `isRecovered` | boolean | The payment eventually succeeded after one or more failed retry attempts. | Example A payment can have `status: "succeeded"`, `isCaptured: false`, and `isFullyReversed: false` at the same time. This means the authorization was approved but the funds have not yet been captured — the `displayStatus` would be `uncaptured`. warning Do not use `displayStatus` as the primary signal in your integration logic. It can expand with new values in future releases. Always branch on `status` and the individual boolean flags. ## Amounts The captured and refunded amounts are also surfaced directly on the payment object: | Field | Meaning | | ---------------- | ---------------------------------------------------------------------------- | | `amountCaptured` | Amount that has been captured, in minor currency units (e.g. cents). | | `amountReversed` | Amount that has been reversed (refunded or voided), in minor currency units. | --- # Refunds and Voids Use the [`POST /v2/payments/{id}/reverse`](https://docs.odus.com/docs/api/core/v2/payment-controller-reverse) endpoint to refund a captured payment or void an uncaptured authorization. The same endpoint handles both operations — ODUS decides which action to perform based on the current state of the payment. ## Prerequisites * You need a Secret API Key to call the reverse endpoint. * The payment must have `status: "succeeded"` and must not be fully reversed (`isFullyReversed: false`). ## Full refund To refund the entire captured amount, send a `POST /v2/payments/{id}/reverse` request with an empty body. Full refund — request body ``` {} ``` ODUS will refund the remaining unreversed amount (`amountCaptured - amountReversed`) to the customer. ### From the Dashboard 1. Open the payment details page. 2. Click **Refund**. 3. Enter the full amount and click **Refund**. ## Partial refund To refund a specific amount, include the `amount` field in minor currency units (for example, `500` for $5.00 USD). Partial refund — request body ``` { "amount": 500 } ``` You can issue multiple partial refunds against the same payment. The total refunded amount cannot exceed `amountCaptured`. ### From the Dashboard 1. Open the payment details page. 2. Click **Refund**. 3. Enter the amount you want to refund and click **Refund**. ## Voiding an uncaptured payment A void cancels an authorization that has not yet been captured. It releases the reserved funds without charging the customer and does not incur a refund fee. To void a payment, send `POST /v2/payments/{id}/reverse` with an empty body, exactly as you would for a full refund. ODUS automatically issues a void instead of a refund when the payment has not been captured (`isCaptured: false`). Void — request body ``` {} ``` warning You cannot void a payment after it has been captured. Once `isCaptured` is `true`, only a refund is possible. ## Recording an external refund If you have already processed a refund directly at the gateway — outside of ODUS — you can record it in ODUS without sending a new refund request by setting `isExternal: true`. External refund — request body ``` { "amount": 1500, "isExternal": true } ``` ODUS will update the payment record and the reversal amounts, but will not contact the gateway. Omit `amount` to record a full external refund. ## Response The response is the full Payment object. After a reversal, check `amountReversed` for the total reversed amount, `isReversed` to confirm at least one reversal was applied, and `isFullyReversed` to check whether further refunds are possible. See the [`POST /v2/payments/{id}/reverse`](https://docs.odus.com/docs/api/core/v2/payment-controller-reverse) reference for the full response schema. ## Constraints * The `amount` must not exceed `amountCaptured - amountReversed`. Attempting to refund more than the remaining capturable amount will return a `400` error. * A void is only possible when `isCaptured` is `false`. Sending a reverse request on a captured payment always triggers a refund. * `isExternal: true` can be combined with a partial `amount`. It follows the same amount constraints as a regular refund. --- # Products and Prices This section covers how products and prices work in ODUS. A product represents something you sell, and a price defines the billing terms for that product — the amount, the currency, and whether the charge is one-time or recurring. Together, products and prices are the foundation for creating subscriptions. * [What are products and prices?](https://docs.odus.com/docs/guides/products/overview.md) — Understand the concepts before you start building. * [Creating products and prices](https://docs.odus.com/docs/guides/products/creating-products-and-prices.md) — Step-by-step instructions for creating and managing products and prices via the API and the Dashboard. --- # Creating products and prices This guide explains how to create products and prices, and how to manage them after creation. ## Prerequisites * You have a valid ODUS API secret key. * You understand the difference between products and prices. If not, read [What are products and prices?](https://docs.odus.com/docs/guides/products/overview.md) first. ## Creating a product A product is the starting point. You must create a product before you can attach any prices to it. **Via the Dashboard:** Go to **Products → Products** and click **Add**. Fill in the name and, optionally, a description, a statement descriptor and a statement descriptor suffix. **Via the API:** Send a [`POST /v2/products`](https://docs.odus.com/docs/api/core/v2/product-controller-create) request: ``` { "name": "Pro Plan", "description": "Full access to all Pro features", "statementDescriptor": "MYAPP PRO PLAN", "statementDescriptorSuffix": "PRO PLAN" } ``` See the [`POST /v2/products`](https://docs.odus.com/docs/api/core/v2/product-controller-create) reference for the full parameter list. The response includes the product `id`. You will need this ID when creating prices. ### Fulfillment settings A product also carries what [Order Management System](https://docs.odus.com/docs/guides/order-management/.md) needs to ship it: **Shippable** (the product takes part in Order Management) and **SKU** (what the warehouse ships it under — a shippable product without one stays on its orders but never ships). **Category** is unrelated to fulfillment: it is sent as metadata to payment service providers, for example for VAT reporting. See [What the SKU decides](https://docs.odus.com/docs/guides/order-management/how-orders-are-created.md#what-the-sku-decides) for what each setting changes on the resulting order, and how to keep one price out of fulfillment when an add-on shares its product with goods that ship. ## Attaching a recurring price A recurring price charges the customer on a repeating schedule. `amount` is what each billing cycle charges, and `recurringSchedule` sets how often. **Via the Dashboard:** Go to **Products → Prices** and click **Add**. Select the product, choose **Recurring** as the type, select the currencies, and set the interval and amount for each currency. **Via the API:** Send a [`POST /v2/prices`](https://docs.odus.com/docs/api/core/v2/price-controller-create) request: ``` { "name": "Pro Plan — Monthly", "type": "recurring", "currency": "usd", "amount": 2999, "product": "prod_2QGNGy637hJ9MnD5cK7thAjSvPV", "recurringSchedule": { "intervalUnit": "month", "intervalCount": 1 } } ``` See the [`POST /v2/prices`](https://docs.odus.com/docs/api/core/v2/price-controller-create) reference for the full parameter list. `recurringSchedule` takes: * `intervalUnit` — `minute`, `hour`, `day`, `week`, `month`, or `year`. Required. * `intervalCount` — the number of units between charges. Required. * `trial` — optional. A first cycle with its own `intervalUnit`, `intervalCount` and `amount`, charged before the regular `amount` takes effect. A monthly price with a 7-day trial for $1.99 ``` { "name": "Pro Plan — Monthly with trial", "type": "recurring", "currency": "usd", "amount": 2999, "product": "prod_2QGNGy637hJ9MnD5cK7thAjSvPV", "recurringSchedule": { "intervalUnit": "month", "intervalCount": 1, "trial": { "intervalUnit": "day", "intervalCount": 7, "amount": 199 } } } ``` The text on the customer's bank statement comes from the product's `statementDescriptor` or `statementDescriptorSuffix`, not from the price. Which one a gateway uses is explained in [Statement descriptors](https://docs.odus.com/docs/guides/products/overview.md#statement-descriptors). Moving from v1 `billingSchedule` is not part of v2. Replace it with `recurringSchedule`, and transform the value as well as the key: the two describe the schedule differently. Send one or the other, never both. A schedule whose first cycle differs from the rest maps to `trial`, as in the example above. A schedule that keeps changing after its first cycle — a different amount or interval on the second cycle or later — has no v2 equivalent. tip To offer the same plan in multiple currencies at once, use the Dashboard form. It lets you define amounts for several currencies in a single step, creating one price object per currency. ## Attaching a one-time price A one-time price charges the customer a fixed amount exactly once. **Via the Dashboard:** Go to **Products → Prices**, click **Add**, and select **One-time** as the type. **Via the API:** Send a [`POST /v2/prices`](https://docs.odus.com/docs/api/core/v2/price-controller-create) request with `type: "one_time"` and an `amount`: ``` { "name": "Lifetime Access", "type": "one_time", "currency": "usd", "amount": 29900, "product": "prod_2QGNGy637hJ9MnD5cK7thAjSvPV" } ``` One-time prices take no `recurringSchedule`. ## Selling a bundle of units If a price represents several units of the same thing — a pack of four sessions, a case of twelve bottles — set `quantity` on the price. The `amount` still covers the whole pack, and ODUS splits it into a per-unit amount when the price is used: ``` { "name": "Session Pack", "type": "one_time", "currency": "usd", "amount": 14796, "quantity": 4, "product": "prod_2QGNGy637hJ9MnD5cK7thAjSvPV" } ``` A payment referencing this price produces a line item of quantity `4` at `3699` each, so wallets and gateway order sheets show `4x Session Pack — $36.99` instead of `1x Session Pack — $147.96`. The customer is charged the same `147.96` either way. `quantity` defaults to `1`, so existing prices are unaffected. Two further rules apply: * The price `amount` (and the trial amount of a recurring price) must divide evenly by `quantity`, so each unit has a whole per-unit amount. * A `quantity` sent on the line item multiplies the price quantity. Ordering `quantity: 2` of the pack above yields 8 units, charged `295.92`. ## Giving an item away for free A price can have an `amount` of `0`. That is how you sell a free item alongside a paid one — a gift with purchase, a bundled sample, a no-cost add-on — instead of discounting the paid product and losing the extra from the order. ``` { "name": "Free Shaker Bottle", "type": "one_time", "currency": "usd", "amount": 0, "product": "prod_2QGNGy637hJ9MnD5cK7thAjSvPV" } ``` Put the free price in the same cart as the paid one. The customer is charged the paid total, and both items appear on the payment, on the gateway's order sheet, and in Order Management, so the freebie is picked, packed and shipped like anything else. A **recurring** price of `0` becomes an ordinary subscription that renews at no cost: every cycle produces a paid invoice and a succeeded payment, and the order is created as usual — the customer's card is simply never charged. Nothing zero-valued is ever sent to a payment provider. note Make a recurring item free by setting the **price** amount to `0`, not by overriding the amount on a line item. A subscription bills from its price, so a line-item override applies to the first charge only and the item would renew at full price. ODUS rejects that override rather than let it happen. ## Editing a price You can update a price's name, active status, quantity, recurring schedule, and metadata after creation. **Via the Dashboard:** Open the price from **Products → Prices** and edit the fields directly. **Via the API:** Send a [`POST /v2/prices/{id}`](https://docs.odus.com/docs/api/core/v2/price-controller-update) request with the fields you want to change: ``` { "name": "Pro Plan — Monthly (Updated)" } ``` note The currency of a price cannot be changed after creation. If you need a price in a different currency, create a new price. ## Deleting a price **Via the Dashboard:** Open the price from **Products → Prices**, click the actions menu, and select **Delete**. **Via the API:** Send a [`POST /v2/prices/{id}/delete`](https://docs.odus.com/docs/api/core/v2/price-controller-delete) request. No request body is required. warning A price cannot be deleted if it has already been used to make a payment. If you no longer want customers to use a price, set `active` to `false` instead. Inactive prices are hidden in the Dashboard but remain accessible via the API. --- # What are products and prices? In ODUS, **products** and **prices** are the building blocks for billing. Before you can create a subscription or charge a customer, you need at least one product with at least one price attached to it. ## Products A product represents a sellable item or service in your catalog — for example, a SaaS plan, a physical product, or a content subscription. A product does not contain any billing information on its own. It has a name, an optional description, and an optional statement descriptor. The statement descriptor is the text that appears on a customer's bank statement when they are charged. It is limited to 22 characters. A product can also have a **statement descriptor suffix**: a short text that some gateways add after your account's own descriptor instead of replacing it. See [Statement descriptors](#statement-descriptors). ## Prices A price defines the billing terms for a product. Every price belongs to exactly one product and specifies: * The **amount** to charge, in the smallest currency unit (for example, cents for USD). * The **currency** to charge in. * The **type** of charge: either `one_time` or `recurring`. ### One-time prices A one-time price charges the customer a fixed amount exactly once. This is suitable for setup fees, lifetime access purchases, or any charge that is not repeated. ### Recurring prices A recurring price charges the customer on a repeating schedule. The price's `amount` is what each billing cycle charges, in the smallest currency unit, and its `recurringSchedule` sets how often: | Field | Description | | --------------- | ----------------------------------------------------------------------------------------------- | | `intervalUnit` | The unit of time for the billing interval: `minute`, `hour`, `day`, `week`, `month`, or `year`. | | `intervalCount` | The number of units between each charge. For example, `1` with `month` means monthly. | | `trial` | Optional. A first cycle with its own `intervalUnit`, `intervalCount` and `amount`. | For example, a monthly plan at $29.99 has `amount: 2999` and `recurringSchedule: { "intervalUnit": "month", "intervalCount": 1 }`. See [Attaching a recurring price](https://docs.odus.com/docs/guides/products/creating-products-and-prices.md#attaching-a-recurring-price) for a full request. ## Multiple prices per product A product can have more than one price. This is how you offer different billing options for the same service. For example, a "Pro Plan" product might have a monthly price and an annual price. When creating a subscription, you specify which price to use, not just which product. This also makes it possible to add new pricing options later without touching existing subscriptions. Existing subscriptions keep their original price until you explicitly change them. ## Products, prices, and subscriptions When a customer subscribes to something, you create a subscription by referencing a price. ODUS looks up the product and recurring schedule from that price automatically. The subscription then renews according to that schedule. This relationship can be summarised as: * A **product** describes *what* is being sold. * A **price** describes *how* it is billed. * A **subscription** is a specific customer's active agreement to be billed according to a price. ## Statement descriptors Card statements show at most 22 characters, and gateways build that text in one of two ways. That is why a product has two descriptor fields: | Field | What the gateway does with it | Used by | | --------------------------- | ------------------------------------------------- | ----------------------------------------------------------------- | | `statementDescriptor` | Replaces your account's descriptor with this text | Adyen, Checkout.com, and Stripe by default | | `statementDescriptorSuffix` | Adds this text after your account's descriptor | PayPal, and Stripe when the gateway profile's suffix option is on | Write the suffix to be read after your account's descriptor, and keep it short. The gateway shows your account's descriptor, a separator, then the suffix, and cuts the line at 22 characters. For example, with the account descriptor `ACME`, the suffix `PRO PLAN` reads `ACME* PRO PLAN` on a Stripe statement. * **PayPal** sends the suffix when the payment has one, and the full descriptor otherwise. * **Stripe** sends the full descriptor unless you turn on **Send the statement descriptor suffix instead of the full descriptor** on the gateway profile; see [Stripe](https://docs.odus.com/docs/guides/integrations/gateways/stripe/.md#statement-descriptors). A payment takes both values from the product of its first cart item. You can override either one when you create the payment, and each falls back to the product separately. Subscription payments take them from the subscribed product. --- # Reports Reports give you aggregated views of payment and customer data across your business. Use them to track revenue performance, compare segments, and export data for offline analysis. * [Payment Summary Report](https://docs.odus.com/docs/guides/reports/payment-summary-report.md) — first-charge vs. recurring payment counts, approval rates, refunds, chargebacks, and net revenue, broken down by product, currency, date, gateway, or attribution channel. * [Customer Value Report](https://docs.odus.com/docs/guides/reports/customer-value-report.md) — first-charge, upsell, rebill, and LTV metrics, broken down by product, price, currency, date, or attribution channel. * [Projected Billing Report](https://docs.odus.com/docs/guides/reports/projected-billing-report.md) — forecast upcoming rebill and recycle revenue using per-attempt approval rates, broken down by currency, gateway, or product. --- # Customer Value Report The Customer Value Report breaks revenue down by first charges, upsells, rebills, and lifetime value across any segment of your business — products, prices, currencies, dates, or attribution channels. Use it to compare cohort performance across products or campaigns, monitor AOV and LTV, or feed the same numbers into your own tools via the API. ## Open the report In the ODUS Dashboard, navigate to **Insights → Customer value**. The page shows a summary chart of headline metrics and a detailed table below. ## Date range and timezone The date range selector controls the **cohort window**. Switch between **UTC** and your merchant timezone to align daily boundaries with your business hours. The merchant timezone is configured in **Settings → Merchant Configuration**; if unset, the report falls back to UTC. The date filter applies to each customer's **first charge**, not to every payment. A customer is included if their first charge falls inside the window — all of their later upsells and rebills are then attributed to that cohort, regardless of when those later payments occurred. See [How the cohort is defined](#how-the-cohort-is-defined) below. ## Grouping Use the **Group by** tabs to organize rows by product, price, currency, calendar day, UTM source, or UTM campaign. Switching grouping updates the chart and table immediately. ## Filtering Open the **Filters** panel from the top-right of the report to narrow the cohort. Filters apply to the chart, the table and the CSV export alike, and they are written into the page URL — so a filtered view can be bookmarked, reloaded, or shared. The filters you apply are also remembered per report in this browser, so returning to it later reopens the slice you left it on — unless you arrive on a link that already carries filters, which always win. **Clear all** forgets them. Nothing is refetched until you press **Apply**. warning Every filter here selects the cohort **by its first charge**, exactly as the date range does. Filtering on `currency = EUR` means "customers whose first charge was in EUR" — their upsell and rebill revenue is still counted in full, including any of it taken in another currency. The same goes for product, price, gateway profile, payment method, and every UTM field: they describe the first charge, not the lifetime. | Filter | Selects customers whose first charge… | | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Product**, **Price** | …contained a line item for that product or price. A multi-item first charge counts once, at its full amount, in every matching cohort. The pickers offer your whole catalogue, newest first, deactivated entries included and labelled `(inactive)`; same-named entries show their creation date. | | **Currency** | …was taken in that currency. | | **First charge amount** | …was for an amount in the given range. | | **Gateway profile** | …was processed on that profile. | | **Payment method type** | …used that kind of instrument (card, PayPal, Apple Pay). | | **Status** | …ended in one of the given display statuses. | | **Billing country** | (customer-level) …belongs to a customer whose billing address carries one of the chosen countries, picked by name from the country list. This is the address they gave you, not the country that issued the card. | | **Shipping country** | (customer-level) …belongs to a customer with one of the chosen shipping-address countries — where the order goes. A customer with no shipping address on file has no country to match, so they drop out of the cohort whenever this filter is set. | | **UTM source / medium / campaign / term / content / id** | …carried that attribution. | The API additionally accepts **Payment method** (a specific saved instrument, `paymentMethod`), which the panel does not offer because merchants segment by instrument *kind*, not by one customer's stored card. Because the cohort is anchored to the first charge, a filter can only ever *remove whole customers*. It never removes part of a customer's history, so LTV and the AOV metrics stay comparable across filtered views. When you filter by product or price **and** group by product or price, only the filtered row is shown — a first charge that also held other products still counts in full towards it, but those products do not appear as rows of their own. ## Metrics | Metric | Definition | | -------------------- | -------------------------------------------------------------- | | **FC Quantity** | Number of first charges in the cohort. | | **FC Amount** | Total revenue from first charges. | | **FC AOV** | `FC Amount / FC Quantity`. | | **Upsells Quantity** | Number of upsell transactions made by cohort customers. | | **Upsells Amount** | Total upsell revenue. | | **Upsells AOV** | `Upsells Amount / Upsells Quantity`. | | **Total AOV** | `(FC Amount + Upsells Amount) / FC Quantity`. | | **Rebills Quantity** | Number of subscription renewal charges by cohort customers. | | **Rebills Amount** | Total rebill revenue. | | **LTV** | `(FC Amount + Upsells Amount + Rebills Amount) / FC Quantity`. | The **Total** row aggregates the cohort across all groups. The summary chart shows **FC AOV**, **Upsells AOV**, **Total AOV**, and **LTV** for the full cohort. All monetary metrics are converted to your **display currency** (set in **Settings → Merchant Configuration**) using the exchange rate effective at the time of each payment, so historical totals remain stable. ## How the cohort is defined The cohort is built by anchoring each customer to their first charge. A customer enters the report when that first charge falls inside the selected window; once in, every later upsell and rebill they make is counted, even past the window's end date. Consequences: * Shrinking the end date does not remove upsell or rebill revenue for customers already in the cohort — only the time available for that revenue to accumulate changes. * A customer whose first charge predates the start date is excluded entirely, even if they paid during the window. This anchoring is what makes LTV comparable across periods. ### First-charge eligibility The first charge is the **earliest payment attempt** the customer has ever made, across any product. Eligible payment outcomes: * Succeeded * Reversed (fully refunded) * Partially reversed * Uncaptured (authorized but not captured) * Chargeback * Cancelled (voided authorization) Failed and declined payments are ignored and never anchor a cohort. ### Cancelled first charges A cancelled payment anchors the cohort but is **not counted as a real first charge**. Anchoring exists only to prevent a later upsell from being misclassified as the customer's first product when their true first attempt was voided. For a customer whose first charge was cancelled: * They do **not** contribute to `FC Quantity`, `FC Amount`, `FC AOV`, `Total AOV`, or `LTV`. * Their later upsell and rebill payments are still counted normally. * They will not appear under the product or price row of any later upsell, because the cancelled payment correctly anchors them to their original product. ### Upsells vs. rebills After the first charge, every later non-cancelled payment is classified as exactly one of: * **Rebill** — settles a recurring subscription invoice (scheduled renewal). * **Upsell** — any other post-FC payment (one-time purchases, order bumps, manual charges). Cancelled post-FC payments are excluded from both totals. ### Multi-item carts When grouped by **Product** or **Price**, a payment whose cart contains multiple line items is split across rows proportionally to each line item's share of the payment amount. This keeps row totals consistent with the cohort total. The other groupings assign each payment to exactly one row. ## Example Suppose the report's date range is **2026-06-01 → 2026-06-30** and five customers interact with the **Pro Plan** ($50/mo): | Customer | First-ever charge | Later activity (any date) | In cohort? | | -------- | ------------------------------------- | ------------------------------------------------------------------- | ----------------------------------------- | | Alice | 2026-06-03 — $50 (paid) | 2026-06-10 upsell $20; 2026-07-03 rebill $50 | **Yes** — counts as FC | | Bob | 2026-06-15 — $50 (paid) | none | **Yes** — counts as FC | | Carol | 2026-06-20 — $50 (paid) | 2026-06-25 upsell $40; 2026-07-20 rebill $50; 2026-08-20 rebill $50 | **Yes** — counts as FC | | Dave | 2026-05-28 — $50 (paid) | 2026-06-12 rebill $50 | **No** — first charge predates the window | | Erin | 2026-06-08 — $50 (cancelled / voided) | 2026-06-09 upsell $30; 2026-07-05 rebill $50 | **Yes (anchored only)** — see below | Erin's first-ever charge was authorized but cancelled before capture. The cancelled payment **anchors** her cohort (so her later $30 upsell is still classified as an upsell, not as a first charge), but it does **not** count as a real FC — she contributes $0 to FC metrics. Her later upsell and rebill are counted normally. Grouped by **Product → Pro Plan**, the cohort row is: | FC Quantity | FC Amount | FC AOV | Upsells Qty | Upsells Amount | Upsells AOV | Total AOV | Rebills Qty | Rebills Amount | LTV | | ----------- | --------- | ------ | ----------- | -------------- | ----------- | --------- | ----------- | -------------- | ------ | | 3 | $150 | $50 | 3 | $90 | $30 | $80 | 4 | $200 | \~$147 | How each number is derived: * **FC Quantity = 3** — Alice, Bob, Carol. Dave is excluded entirely; Erin's cancelled FC does not count. * **FC AOV = $50** — `$150 / 3`. * **Upsells Quantity = 3** — Alice's $20 + Carol's $40 + Erin's $30. Erin's upsell counts even though her FC didn't. * **Upsells AOV = $30** — `$90 / 3 upsells`. * **Rebills Quantity = 4** — Alice's July rebill, Carol's July and August rebills, Erin's July rebill. Dave's June rebill is excluded with him. * **Total AOV = $80** — `($150 FC + $90 upsells) / 3 cohort customers`. * **LTV ≈ $146.67** — `($150 + $90 + $200) / 3`. ## Export to CSV Click **Export CSV** in the top-right of the table to download the current view. The file contains one row per group plus a Total row, with monetary values in your display currency. The selected date range is included in the filename. ## API access The same data is available via [`GET /v2/reports/customer-value`](https://docs.odus.com/docs/api/core/v2/reporting-controller-get-customer-value-report). The endpoint supports all grouping options, metric selection, and every filter listed above — each published as a query parameter in the API reference, and each carrying the same first-charge semantics. ### Example ``` GET /v2/reports/customer-value?groupBy=product.name&createdAt[gte]=2026-01-01T00:00:00Z&createdAt[lte]=2026-01-31T23:59:59Z ``` The LTV of customers acquired from one campaign whose first charge was in EUR: ``` GET /v2/reports/customer-value?groupBy=product.name&createdAt[gte]=2026-01-01T00:00:00Z&createdAt[lte]=2026-01-31T23:59:59Z&attribution.utmSource[eq]=fb¤cy[eq]=eur ``` The response contains a `data` array (one entry per group) and a `total` object with the cohort aggregate. --- # Payment Summary Report The Payment Summary Report aggregates transaction outcomes across your business and splits them into two payment buckets — **first-charge (FC)** and **recurring** — alongside refund, chargeback, gross and net revenue metrics. Use it to monitor approval rates, track refunds and chargebacks, and compare gateway, product, or campaign performance. ## Open the report In the ODUS Dashboard, navigate to **Insights → Payments**. The page shows a summary chart of revenue, refunds, and chargebacks, with a detailed breakdown table below. ## Date range and timezone The date range selector filters payments by their **`createdAt`** timestamp. Switch between **UTC** and your merchant timezone to align daily boundaries with your business hours. The merchant timezone is configured in **Settings → Merchant Configuration**; if unset, the report falls back to UTC. Unlike the [Customer Value Report](https://docs.odus.com/docs/guides/reports/customer-value-report.md), every payment is evaluated independently — there is no cohort anchoring. A payment is included whenever its creation time falls inside the window. ## Grouping Use the **Group by** tabs to organize rows by product, currency, calendar day, gateway profile, UTM source, or UTM campaign. Switching grouping updates the chart and table immediately. ## Filtering Open the **Filters** panel from the top-right of the report to narrow it to a segment of your book. Filters apply to the chart, the table and the CSV export alike, and they are written into the page URL — so a filtered view can be bookmarked, reloaded, or shared with a colleague. The filters you apply are also remembered per report in this browser, so returning to it later reopens the slice you left it on — unless you arrive on a link that already carries filters, which always win. **Clear all** forgets them. Nothing is refetched until you press **Apply**. Filtering never changes how a payment's revenue is attributed. A row that survives a filter carries exactly the number it carried before — filtering selects rows, it does not recalculate them. When you filter by product or price **and** group by product, only the filtered product's row is shown. A payment whose cart also held other products still counts in full towards that row, but its cart-mates do not appear as rows of their own. | Filter | Notes | | -------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Product**, **Price** | Selects payments whose cart contains a line item for that product or price. A cart with several line items still counts once, at its full amount. Excluding a product (`nin`) removes every payment carrying it, even alongside others. The pickers offer your whole catalogue, newest first — deactivated entries included and labelled `(inactive)`, since historical payments still reference them; when several entries share a name, each also shows its creation date. | | **Payment type** | `initial` or `recurring`. Prefer this over `offSessionType`, which is unset on an initial charge and therefore skips them all under a "not recurring" filter. | | **Gateway profile** | The profile the payment was actually processed on, read from its latest transaction — which a cascade may have changed. The *requested* profile (`paymentMethodOptions.card.gatewayProfile`) is filterable on the API but not offered in the panel. | | **Currency**, **Status**, **Amount** | Payment-level fields. | | **Card brand**, **Last four**, **Expiry** | Read from the payment method. | | **BIN** | Read from the payment method. Choose **is** to match one BIN exactly, or **is between** to select a range: each bound stands for every card number that starts with it, so `4688` to `4688` selects every BIN beginning with `4688`. A payment whose BIN has fewer digits than your bounds is left out when it could fall on both sides of a bound — see [Filtering by a range of card BINs](https://docs.odus.com/docs/guides/api-features/pagination-filtering-sorting.md#filtering-by-a-range-of-card-bins). | | **Billing country** | The country on the customer's billing address, chosen by name from the country list. Pick several to report on a whole market. Note this is the address they gave you, not the country that issued the card. | | **Shipping country** | The customer's shipping-address country — where the order goes. Chosen the same way. A customer with no shipping address on file has no country to match, so those payments drop out of the report whenever this filter is set. | | **UTM source / medium / campaign / term / content / id** | Attribution captured at checkout. | Filterable on the API but not offered in the panel: **Cascade**, **Payment method type**, **Card mode**, **Card expiry**, the **requested** gateway profile (`paymentMethodOptions.card.gatewayProfile`, as opposed to the used one above), **Decline type** (`soft_decline`, `hard_decline` or `not_attempted`) and **Retry attempts**. Every parameter the route publishes is listed in the [API reference](https://docs.odus.com/docs/api/core/v2/reporting-controller-get-payment-summary-report). ## What counts as a payment A payment enters the counts once it can be classified: it was **approved**, it **failed**, or — for a first-charge payment — it is **incomplete**, meaning the customer is still with their bank or wallet approving it. Every counted payment falls into exactly one outcome column, so: * **FC Payments Count** = FC Successful + FC Incomplete + FC Failed * **Recurring Payments Count** = Recurring Successful + Recurring Declined A renewal is charged without the customer present, so the recurring bucket has no incomplete column, and a renewal counts as declined whenever its latest attempt came back with a `declineType` — whatever that type says. That covers a refusal, an outcome the processor never resolved, and a payment that was never authorized against the payment method at all. A renewal attempt that failed without producing a `declineType` is not classified and is counted nowhere; the same first-charge attempt counts as failed. Two more kinds of payment cannot be classified and are counted nowhere: a checkout that was opened but never submitted (display status `unattempted`), and a payment cancelled without ever being declined — withdrawn by you, or blocked by one of your customer restrictions before it reached the gateway. The second kind is left out of the report entirely, so it cannot produce a row of its own either. A payment that *was* declined and cancelled afterwards is still a decline, and is counted as one. Each payment is classified as either **first-charge (FC)** or **recurring** based on its `offSessionType`: * **Recurring** — payments generated by the subscription billing engine to renew an active subscription (`offSessionType = recurring`). * **First-charge (FC)** — every other payment. This includes on-session checkout payments and unscheduled merchant-initiated charges. The "FC" label is a bucket name for "non-recurring" — it does **not** mean the customer's first-ever payment. note "FC" in this report has a different meaning than in the [Customer Value Report](https://docs.odus.com/docs/guides/reports/customer-value-report.md). There, FC strictly means a customer's lifetime first charge. Here, it is shorthand for any non-subscription-renewal payment, regardless of whether the customer has paid before. Both buckets are reported side-by-side so you can compare new payment activity against subscription renewal performance. ## Metrics ### First-charge metrics | Metric | Definition | | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **FC Payments Count** | Non-recurring payments classified as approved, failed or incomplete: the sum of FC Successful, FC Incomplete and FC Failed. | | **FC Successful Count** | FC payments with status `succeeded`, `reversed`, `partially_reversed`, `uncaptured`, or `chargeback`. | | **FC Successful Items** | Total line items across all successful FC payments (a multi-item cart contributes one row per line item). | | **FC Incomplete Count** | FC payments still awaiting the customer, who was sent to their bank or wallet to approve the payment and has not yet returned (`displayStatus` `incomplete`). | | **FC Failed Count** | FC payments whose latest attempt came back with a `declineType`, whatever it says (one you cancelled afterwards still counts), plus those that ended in a failed state without one — sent to a redirect the customer did not complete within a day, or ended before the gateway answered. Most carry `displayStatus` `failed`. | | **FC Revenue** | Gross revenue from successful FC payments. | | **FC Approval Rate** | `FC Approved / (FC Approved + FC Failed) × 100`, where *approved* excludes chargebacks. Incomplete payments are on neither side until they resolve. | ### Recurring metrics | Metric | Definition | | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Recurring Payments Count** | Recurring payments classified as approved or declined: the sum of Recurring Successful and Recurring Declined. | | **Recurring Successful Count** | Recurring payments with status `succeeded`, `reversed`, `partially_reversed`, `uncaptured`, or `chargeback`. | | **Recurring Successful Items** | Total line items across all successful recurring payments. | | **Recurring Declined Count** | Recurring payments whose latest attempt came back with a `declineType`, whatever it says. Counted whether a retry is scheduled, or the payment was cancelled because the failure was final or its retries ran out. A payment recovered by a later retry moves to Recurring Successful. | | **Recurring Revenue** | Gross revenue from successful recurring payments. | | **Recurring Approval Rate** | `Recurring Successful / (Recurring Successful + Recurring Declined) × 100`; chargebacks are included in both values. | info A renewal that is being retried counts as declined until a retry is approved, so the Recurring Approval Rate for a recent period rises as retries land. ### Refunds, chargebacks, and totals | Metric | Definition | | --------------------------- | ------------------------------------------------------------------ | | **Refunded Payments Count** | Payments with status `reversed` or `partially_reversed`. | | **Refund Amount** | Total refunded amount (full and partial). | | **Refund Percentage** | `Refund Amount / Gross Successful Revenue × 100`. | | **Chargebacks Count** | Payments with status `chargeback`. | | **Chargeback Amount** | Total amount of chargebacked payments. | | **Chargeback Percentage** | `Chargeback Amount / Gross Successful Revenue × 100`. | | **Total Gross Revenue** | All successful revenue across FC and recurring, before deductions. | | **Total Net Revenue** | `Total Gross Revenue − Refund Amount − Chargeback Amount`. | The **Total** row aggregates each metric across all groups. All monetary metrics are converted to your **display currency** (set in **Settings → Merchant Configuration**) using the exchange rate effective at the time of each payment, so historical totals remain stable. ## Multi-item carts When grouped by **Product**, a payment whose cart contains multiple line items is split across rows proportionally to each line item's share of the payment amount. Counts use a distinct-payment formula so a multi-item cart still counts as one payment. Other groupings assign each payment to exactly one row. ## Example Say your store processed the following six payments on **2026-06-01**, all in your display currency: | Payment | Type | Amount | Outcome | | ------- | -------- | ------ | ------------------------- | | #1 | Checkout | $50 | Succeeded | | #2 | Checkout | $50 | Declined | | #3 | Checkout | $80 | Succeeded | | #4 | Checkout | $80 | Refunded ($80) | | #5 | Rebill | $30 | Succeeded | | #6 | Rebill | $30 | Declined, retry scheduled | Grouped by **Day**, the report row for 2026-06-01 looks like this: | FC Count | FC Successful | FC Failed | FC Revenue | FC Approval | Recurring Count | Recurring Successful | Recurring Revenue | Recurring Approval | Refund Amount | Total Gross | Total Net | | -------- | ------------- | --------- | ---------- | ----------- | --------------- | -------------------- | ----------------- | ------------------ | ------------- | ----------- | --------- | | 4 | 3 | 1 | $210 | 75% | 2 | 1 | $30 | 50% | $80 | $240 | $160 | How each number is derived: * **FC Count = 4** — payments #1–#4 are all non-recurring (checkout). * **FC Successful = 3** — #1, #3, #4 (a refunded payment was originally successful). * **FC Revenue = $210** — gross of the three successful FC payments ($50 + $80 + $80), before subtracting refunds. * **FC Approval = 75%** — `3 approved / (3 approved + 1 failed) × 100`. * **Recurring Successful = 1** and **Approval = 50%** — one of the two rebills (#5, #6) succeeded. #6 counts as declined while its retry is pending; if the retry is approved, it moves to Recurring Successful and the rate becomes 100%. * **Refund Amount = $80** — payment #4 was fully refunded. * **Total Gross = $240** — `$210 FC + $30 recurring`. * **Total Net = $160** — `$240 − $80 refund − $0 chargebacks`. ## Export to CSV Click **Export CSV** in the top-right of the table to download the current view. The file contains one row per group plus a Total row, with monetary values in your display currency. The selected date range is included in the filename. ## API access The same data is available via [`GET /v2/reports/payment-summary`](https://docs.odus.com/docs/api/core/v2/reporting-controller-get-payment-summary-report). The endpoint supports all grouping options, metric selection via `measures`, and every filter listed above — each published as a query parameter in the API reference, with the operators (`eq`, `ne`, `in`, `nin`, `gt`, `gte`, `lt`, `lte`, `like`, `ilike`) its type accepts. String filters are case-sensitive except `ilike`. `paymentMethod.card.bin` also takes a range with `gte` and `lte`, compared as the card numbers each BIN starts rather than as text — see [Filtering by a range of card BINs](https://docs.odus.com/docs/guides/api-features/pagination-filtering-sorting.md#filtering-by-a-range-of-card-bins). The API additionally accepts a `secondaryGroupBy` parameter to split each group along a second dimension (e.g. `groupBy=displayStatus&secondaryGroupBy=currency`). When supplied, each response row carries a `secondaryGroupBy` field with the value of that second dimension. ### Example ``` GET /v2/reports/payment-summary?groupBy=product.name&createdAt[gte]=2026-01-01T00:00:00Z&createdAt[lte]=2026-01-31T23:59:59Z&displayStatus[ne]=unattempted ``` `displayStatus[ne]=unattempted` is optional: an unsubmitted checkout is already left out of every metric (see [What counts as a payment](#what-counts-as-a-payment)), so the filter changes no number. It does keep a group whose only payments were never submitted — a product with no other activity in the window — out of the response, where it would otherwise appear with every metric at zero. Do not exclude `cancelled`: a rebill that was declined and then cancelled once its retries ran out is a decline, and filtering it out removes it from Recurring Declined and inflates the approval rate. Narrowed to one product, paid in EUR, acquired from a single campaign: ``` GET /v2/reports/payment-summary?groupBy=createdAt&granularity=day&createdAt[gte]=2026-01-01T00:00:00Z&createdAt[lte]=2026-01-31T23:59:59Z&product[eq]=prod_2ABc¤cy[eq]=eur&attribution.utmSource[eq]=fb ``` The response contains a `data` array (one entry per group) and a `total` object with the aggregate across all groups. --- # Projected Billing Report The Projected Billing Report forecasts upcoming subscription revenue by combining the **base amount** of every scheduled rebill and recycle (retry) attempt with **per-attempt approval rates** that you supply. Use it to plan cash flow, evaluate the revenue impact of different retry strategies, and identify which billing cycles or retry attempts contribute the most expected revenue. ## Open the report In the ODUS Dashboard, navigate to **Insights → Projected billing**. The page shows a chart of projected revenue over time, projection-rate inputs, and a breakdown table below. ## Date range and timezone The date range selector filters scheduled attempts by their **billing date** — the next scheduled charge date of each active subscription. Only **today and future dates** are valid; past ranges are rejected. Switch between **UTC** and your merchant timezone to align daily boundaries with your business hours. ## Grouping Use the **Group by** tabs to organize rows by **currency**, **gateway profile**, or **product**. The report always groups secondarily by **billing date at day granularity**, so the chart and table show one data point per group per day. ## Filtering Open the **Filters** panel from the top-right of the report to project a slice of the book rather than all of it. Filters apply to the chart, the table and the CSV export alike, and they are written into the page URL — so a filtered projection can be bookmarked, reloaded, or shared. The filters you apply are also remembered per report in this browser, so returning to it later reopens the slice you left it on — unless you arrive on a link that already carries filters, which always win. **Clear all** forgets them. Nothing is refetched until you press **Apply**. The projection rates you supply are applied *after* filtering, so narrowing to one attempt bucket and setting that bucket's rate answers "what would this recycle wave bring in" without the rest of the book diluting the answer. | Filter | Notes | | ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Attempt type** | `rebill` or `recycle`. A rebill is a normal renewal; a recycle is a retry of a failed one. | | **Attempt bucket** | `1`–`4`, where `4` means "4th or later". Combined with **Attempt type**, this isolates a single wave — Recycle #2, say. | | **Product**, **Price** | The product or price the subscription bills on. The pickers offer your whole catalogue, newest first, deactivated entries included and labelled `(inactive)`; same-named entries show their creation date. | | **Gateway profile** | The profile the subscription is configured to bill through. | | **Currency** | The currency of the upcoming charge. | | **Due amount** | The base amount of the upcoming charge, before the projection rate is applied; supports ranges. | ## What counts as a scheduled attempt A subscription contributes one row to the report when **all** of the following are true: * The subscription is **active**. * It has an **upcoming invoice amount** set (i.e. a next scheduled charge exists). * It is either: * **Auto-billing enabled** with no disabled-reason — a normal **rebill**; or * **Currently recycling** (auto-billing was disabled because the latest invoice is being retried) and has a payment with `retry_next_at` populated — a **recycle**. * The scheduled date (`upcoming_invoice_period_start` for rebills, `retry_next_at` for recycles) falls inside the selected window. Cancelled subscriptions, paused subscriptions with no active retry, and subscriptions without an upcoming invoice are excluded. ## Projection horizon: one cycle per subscription Each active subscription contributes **at most one scheduled date** to this report — its very next charge. The next billing date is set only after the current cycle settles (the system bills, and on success schedules the following cycle), so there is never more than one cycle scheduled ahead per subscription. In practice this means: * The report is intended to project the **next billing cycle**. For monthly billing that's roughly the next 30 days; for annual billing it's up to a year out. * A subscription that bills this month **and** rebills again next month will show up in a combined report covering both months (its current scheduled date falls in month 1), but it will **not** appear in a report covering only month 2 — its second cycle isn't scheduled yet. * Widening the date window beyond one billing cycle does not add more projected attempts per subscription; it only catches subscriptions whose next single scheduled date falls further out. To compare windows over longer horizons, run the report repeatedly as cycles complete and new ones get scheduled. ## Rebills vs. recycles Every scheduled attempt is classified as either a **rebill** or a **recycle**: * **Rebill** — the subscription's next scheduled billing cycle. The amount used is the **upcoming invoice amount**. * **Recycle** — a retry of the most recent failed invoice. The amount used is the payment's **next retry amount** (which can differ from the upcoming invoice amount if a retry policy adjusts it). A subscription that is currently recycling is **always** reported as a recycle, never as a rebill, regardless of its current billing cycle. ## Buckets Each attempt is placed into one of four buckets — **#1**, **#2**, **#3**, or **#4+** — based on attempt position. Buckets exist independently for rebills and recycles. * **Rebill #1** is the customer's first rebill (the second total charge, after the initial purchase). **Rebill #2** is the second rebill, and so on. **Rebill #4+** collapses every rebill from the 4th onward. * **Recycle #1** is the first retry of a failed invoice (no prior attempts). **Recycle #2** is the second retry, and so on. **Recycle #4+** collapses every retry from the 4th onward. ## Projection rates For each bucket you supply an **approval rate** between 0% and 100%. The report multiplies each attempt's base amount by the rate of its bucket and sums the result into **Projected Revenue**: ``` Projected Revenue = Σ (base amount × rate of attempt's bucket) ``` Rebill and recycle rates are picked independently per row — a single subscription contributes to exactly one bucket (either a rebill bucket or a recycle bucket) and uses that bucket's rate. Changing a rate updates the chart and table immediately (with a short debounce). Rates default to **0%** if omitted, which produces a Projected Revenue of zero across the board. ## Metrics | Metric | Definition | | --------------------- | ------------------------------------------------------------------------------------------- | | **Total Attempts** | Total number of scheduled attempts in the row (rebills + recycles combined). | | **Projected Revenue** | Sum of base amounts weighted by the per-bucket approval rate, in your **display currency**. | | **Rebill #N Count** | Number of rebill attempts in bucket N. | | **Rebill #N Amount** | Sum of base amounts (upcoming invoice amounts) for rebill bucket N. | | **Recycle #N Count** | Number of recycle attempts in bucket N. | | **Recycle #N Amount** | Sum of base amounts (next retry amounts) for recycle bucket N. | Monetary metrics are converted to your **display currency** (set in **Settings → Merchant Configuration**) using current exchange rates. The **Total** row aggregates each metric across all groups. ## Same-day aggregation Multiple subscriptions billing on the same day collapse into a single row per group per day. Rebill and recycle metrics remain independent within that row — a day can contain any mix of rebills across the four rebill buckets and recycles across the four recycle buckets. ## Example Say you sell a single **Pro Plan** at $50/mo and on **2026-06-02** four active subscriptions are scheduled to charge: | Subscription | Status | Scheduled type | Bucket | Amount | | ------------ | ----------------------- | -------------- | ---------- | ------ | | sub\_A | Active, auto-bill on | Rebill | Rebill #1 | $50 | | sub\_B | Active, auto-bill on | Rebill | Rebill #1 | $50 | | sub\_C | Active, auto-bill on | Rebill | Rebill #2 | $50 | | sub\_D | Recycling (last failed) | Recycle | Recycle #1 | $50 | With projection rates set to **Rebill #1 = 80%**, **Rebill #2 = 70%**, **Recycle #1 = 40%** (others irrelevant), the row for **Pro Plan → 2026-06-02** shows: | Total Attempts | Rebill #1 Count | Rebill #1 Amount | Rebill #2 Count | Rebill #2 Amount | Recycle #1 Count | Recycle #1 Amount | Projected Revenue | | -------------- | --------------- | ---------------- | --------------- | ---------------- | ---------------- | ----------------- | ----------------- | | 4 | 2 | $100 | 1 | $50 | 1 | $50 | $135 | How each number is derived: * **Total Attempts = 4** — all scheduled attempts that day (3 rebills + 1 recycle). * **Rebill #1 Amount = $100** — sub\_A + sub\_B at $50 each. * **Recycle #1 Amount = $50** — sub\_D, the only retry that day. * **Projected Revenue = $135** — `($100 × 80%) + ($50 × 70%) + ($50 × 40%) = $80 + $35 + $20`. If you raise the **Recycle #1** rate to 60%, only that bucket's contribution changes: `$80 + $35 + ($50 × 60%) = $145`. ## Export to CSV Click **Export CSV** in the top-right of the table to download the current view. The file contains one row per group plus a Total row, with monetary values in your display currency. ## API access The same data is available via [`GET /v2/reports/projected-billing`](https://docs.odus.com/docs/api/core/v2/reporting-controller-get-projected-billing-report). Projection rates are passed as comma-separated lists of percentages (0–100), and every filter listed above is published as a query parameter in the API reference. ### Example ``` GET /v2/reports/projected-billing?groupBy=product.name&secondaryGroupBy=billingDate&secondaryGranularity=day&billingDate[gte]=2026-06-01T00:00:00Z&billingDate[lte]=2026-06-30T23:59:59Z&rebillProjections=85,70,60,50&recycleProjections=40,25,15,10 ``` A single recycle wave — the second retry attempt — projected on its own: ``` GET /v2/reports/projected-billing?groupBy=billingDate&granularity=day&billingDate[gte]=2026-06-01T00:00:00Z&billingDate[lte]=2026-06-30T23:59:59Z&attemptType[eq]=recycle&attemptBucket[eq]=2&recycleProjections=0,25,0,0 ``` The response contains a `data` array (one entry per group/day) and a `total` object with the aggregate across all groups. --- # Routing The Routing section explains how ODUS decides which payment gateway to use for each card payment. There are two ways to configure it. A **route** is a decision tree that can read the payment: the card's brand, BIN, issuer and funding type, the amount and currency, and your own metadata. Use it to send different cards to different gateways, with failover after a decline. A **cascade** is a named list of gateway profiles and a rule for selecting between them. When a gateway fails or reaches a configured limit, ODUS automatically routes the payment to the next gateway in the cascade. A cascade does not read the payment. ## Guides * [How routes work](https://docs.odus.com/docs/guides/routing/how-routes-work.md) * [Routing card payments with a route](https://docs.odus.com/docs/guides/routing/routing-card-payments.md) * [How cascade routing works](https://docs.odus.com/docs/guides/routing/cascade-overview.md) * [Configuring cascade rules](https://docs.odus.com/docs/guides/routing/configuring-cascades.md) --- # How Cascade Routing Works A cascade is a named configuration that defines which gateway profiles ODUS uses to process a payment and in what order. When one gateway fails or is no longer available, ODUS automatically routes the payment to the next gateway in the cascade — without any action required from you. ## Why Use a Cascade Without cascade routing, a payment sent to a single gateway has no automatic fallback. If that gateway is unavailable or declines the payment, the charge fails. A cascade addresses this in three ways: * **Automatic failover** — when a gateway fails or rejects a payment, ODUS moves to the next gateway immediately, increasing the chance of a successful charge. * **Load distribution** — traffic can be spread across multiple gateways, preventing any single integration from becoming a bottleneck. * **Protection against downtime** — when a gateway is temporarily unavailable, ODUS continues processing payments through the remaining gateways in the cascade. ## Routing Algorithms Each cascade uses one of three routing algorithms. The algorithm determines how ODUS selects a gateway for each payment. ### Round-robin ODUS distributes payments evenly across all gateways in the cascade, cycling through them in order. Each gateway receives approximately the same number of payments over time. This algorithm is useful when all your gateways have similar capabilities and you want to share load equally. ### Weighted round-robin ODUS distributes payments by percentage weight. You assign a weight to each gateway — for example, 70 to Gateway A and 30 to Gateway B — and ODUS processes roughly 70% of payments through Gateway A and 30% through Gateway B. Weights are whole numbers from 0 to 100. The weights across all gateways in a cascade do not need to sum to 100, but the distribution will be proportional to the values you set. This algorithm is useful when your gateways have different costs, performance characteristics, or transaction limits. ### Priority queue ODUS always tries the highest-priority gateway first. Priority is determined by `position`: the gateway with the lowest position value is tried first. If that gateway fails or is exhausted, ODUS falls back to the gateway with the next-lowest position. Example A cascade has three gateways: | Position | Gateway | | -------- | -------- | | 1 | Primary | | 2 | Backup A | | 3 | Backup B | Every payment goes to Primary first. If Primary fails, ODUS tries Backup A. If Backup A also fails, ODUS tries Backup B. This algorithm is useful when you have a preferred gateway and want fallbacks to activate only on failure. ## Transaction Limits and Caps Each gateway entry in a cascade can have an optional limit and an optional cap. When a gateway exceeds either threshold, ODUS considers it exhausted and skips it for the remainder of that period. | Setting | Description | | ------------------------ | -------------------------------------------------------------------- | | `transactionLimit` | Maximum number of transactions allowed in the period | | `transactionLimitPeriod` | The period when the limit applies: `day`, `week`, or `month` | | `transactionCap` | Maximum total amount (in minor currency units) charged in the period | | `transactionCapPeriod` | The period when the cap applies: `day`, `week`, or `month` | Limits and caps reset automatically at the start of each new period. The fields `isTransactionLimitExhausted` and `isTransactionCapExhausted` on a cascade gateway reflect the current state. warning When all gateways in a cascade are exhausted, the payment cannot be routed and will fail. Make sure at least one gateway is available during each period. --- # Configuring Cascade Routing Rules A cascade defines which gateway profiles ODUS uses to process a payment and how traffic is distributed across them. This guide shows how to create and configure a cascade, assign it as the default for card payments, and verify it is active. ## Prerequisites * You have at least one gateway profile set up. See [Gateway Profiles](https://docs.odus.com/docs/guides/dashboard/gateway-profiles.md). * You understand how cascade routing works. See [How Cascade Routing Works](https://docs.odus.com/docs/guides/routing/cascade-overview.md). ## Create a cascade in the Dashboard 1. In the Dashboard, go to **Cascades**. 2. Click **Add**. 3. Enter a **Title** for the cascade. Optionally enter a **Description**. 4. Select an **Algorithm**: * **Round Robin** — distribute payments evenly across all gateways. * **Weighted Round Robin** — distribute payments by percentage weight. * **Priority Queue** — always try the highest-priority gateway first, falling back on failure or exhaustion. 5. Under **Gateway rules**, click **New rule** to add a gateway. Repeat for each gateway you want in the cascade. 6. Configure each gateway rule. See [Gateway rule options](#gateway-rule-options) for the available fields. 7. Click **Add**. ## Gateway rule options Each rule links a gateway profile to the cascade and controls how that gateway is used. Every rule requires a **Gateway** (the profile to use) and a **Position** (order in which ODUS tries the gateway, 1–10). For **Weighted Round Robin**, each rule also takes a **Weight** — a whole number from 0 to 100 that controls the share of payments routed to that gateway. For **Priority Queue**, each rule can optionally include a **Transaction Limit** with a **Limit Period** (`Day`, `Week`, or `Month`) and a **Transaction Cap** with a **Cap Period**. When a gateway reaches either threshold, ODUS skips it for the rest of that period. See the [`POST /v2/cascades`](https://docs.odus.com/docs/api/core/v2/cascade-controller-create) reference for the full parameter list. important A Priority Queue cascade must include exactly one gateway with no transaction limit and no transaction cap. This gateway acts as the permanent fallback when all other gateways are exhausted. You can drag gateway rules to reorder them. A single cascade can have up to 10 gateway rules. ## Create a cascade via the API Send a [`POST /v2/cascades`](https://docs.odus.com/docs/api/core/v2/cascade-controller-create) request with the cascade's name, algorithm, and at least one gateway. The example below creates a priority-queue cascade with two gateways. The primary gateway has a daily transaction limit and a daily volume cap. The second gateway has no limits and acts as the fallback. ``` { "name": "Primary with fallback", "description": "Routes to the primary gateway up to its daily limits, then falls back.", "algorithm": "priorityQueue", "gateways": [ { "profile": "gwp_2QUGPTQo8As1cNIvgKlSFDxjJDd", "position": 1, "transactionLimit": 500, "transactionLimitPeriod": "day", "transactionCap": 1000000, "transactionCapPeriod": "day" }, { "profile": "gwp_3RTHQUROb9Bt2dJwhLmTGEykKEe", "position": 2 } ] } ``` A successful request returns `201` with the created cascade object, including the `id` you will need for future updates. ## Update an existing cascade To change a cascade's name, description, algorithm, gateways, or active state, send a [`POST /v2/cascades/{id}`](https://docs.odus.com/docs/api/core/v2/cascade-controller-update) request with only the fields you want to change. All fields are optional. ``` { "name": "Primary with fallback — updated", "active": true } ``` You can also edit a cascade in the Dashboard by going to **Cascades**, clicking the cascade, modifying the form, and clicking **Update**. ## Assign the cascade as the default for card payments After creating a cascade, you need to assign it in Merchant Configuration before it processes any payments. 1. In the Dashboard, go to **Settings > Merchant Configuration**. 2. Tick **Set Card payment settings**. 3. Set **Processing mode** to `cascade`. 4. In **Default cascade**, select the cascade you created. 5. Click **Update**. All card payments that do not have a more specific routing assignment will now be processed through this cascade. See [Merchant Configuration](https://docs.odus.com/docs/guides/dashboard/merchant-configuration.md) for the full list of card payment settings. ## Verify the cascade is active The `active` field on the cascade response indicates whether the cascade will be used for routing. A cascade is active by default when created. ``` { "id": "cas_2ROexMdiEIv7Wv3iuIu9RQ6TbGL", "name": "Primary with fallback", "active": true, "algorithm": "priorityQueue", "gateways": [...] } ``` If a cascade is not routing payments as expected, check the `active` field. To reactivate a cascade, send a `POST /v2/cascades/{id}` request with `"active": true`. --- # How Routes Work A route decides which gateway profile ODUS attempts for a card payment, and where it fails over to after a decline. Unlike a [cascade](https://docs.odus.com/docs/guides/routing/cascade-overview.md), a route can read the payment: the card's brand, BIN, issuer and funding type, the amount and currency, who initiated it, and your own metadata. So you can send cards from one issuer to one gateway and everything else to another. Routes apply to card payments only, and are managed through the Core API v2. To create one and point payments at it, see [Routing card payments with a route](https://docs.odus.com/docs/guides/routing/routing-card-payments.md). ## Versions A route is a named container. Its decision tree lives in a **version**, and a version never changes once published. To change a route, publish a new version and activate it. To roll back, activate an earlier one. Only the active version routes payments. Each payment records the version it was routed by, so activating another version later does not change how an earlier payment is explained. ## The decision tree A tree is built from four kinds of node: | Node | What it does | | ----------- | ------------------------------------------------------------------------------ | | `condition` | Tests the payment and follows the first branch that matches, or `else`. | | `split` | Sends a fixed share of payments down each of its outputs, such as 20% and 80%. | | `pool` | Tries its children in turn, and fails over to the next child after a decline. | | `gateway` | Attempts the payment at one gateway profile. | Nodes nest freely: a branch or a split's output can lead to a pool, and a pool can hold conditions. ``` { "type": "condition", "branches": [ { "when": { "field": "card.issuerName", "op": "contains", "value": "Capital One" }, "then": { "type": "pool", "strategy": "ordered", "children": [ { "type": "gateway", "gatewayProfileId": "gwp_2mXGTxXIkqv4uSdbm8DJCzvHUIu" }, { "type": "gateway", "gatewayProfileId": "gwp_3RTHQUROb9Bt2dJwhLmTGEykKEe" } ] } } ], "else": { "type": "gateway", "gatewayProfileId": "gwp_3RTHQUROb9Bt2dJwhLmTGEykKEe" } } ``` Cards issued by Capital One go to the first gateway, and fail over to the second after a decline. Every other card goes to the second gateway directly. ## Conditions A condition is a single comparison, `{ "field", "op", "value" }`, or `all`, `any` and `not` over other conditions. | Field | Operators | Value | | --------------------------- | ---------------------------------------- | ---------------------------------------------- | | `card.brand` | `eq` `ne` `in` `nin` `exists` | A card brand, such as `visa` | | `card.bin` | `startsWith` `exists` | A prefix of 1–8 digits, or a list of them | | `card.issuerName` | `eq` `ne` `in` `nin` `contains` `exists` | The issuing bank's name | | `card.issuerCountry` | `eq` `ne` `in` `nin` `exists` | An ISO 3166-1 alpha-2 code, such as `US` | | `card.fundingType` | `eq` `ne` `in` `nin` `exists` | `credit`, `debit`, `prepaid` or `charge` | | `amount` | `eq` `ne` `gt` `gte` `lt` `lte` | A number in minor units | | `currency` | `eq` `ne` `in` `nin` `exists` | A currency code, such as `usd` | | `initiator` | `eq` `ne` `in` `nin` `exists` | `customer` or `merchant` | | `statementDescriptor` | `eq` `ne` `in` `nin` `contains` `exists` | The payment's statement descriptor | | `statementDescriptorSuffix` | `eq` `ne` `in` `nin` `contains` `exists` | The payment's statement descriptor suffix | | `metadata.` | `eq` `ne` `in` `nin` `contains` `exists` | The value of `` in the payment's metadata | `in` and `nin` take a list, and `exists` takes no value. Metadata compares exactly; every other field ignores case. `statementDescriptor` and `statementDescriptorSuffix` are the values the payment carries: the ones you sent when you created it, or else those of the first product in its cart. When the payment has no value for the field, a condition on it does not match, just as an [unknown card field](#when-card-data-is-unknown) does not. ## Branches A `condition` tests its branches in order and follows the first one that matches. When none matches, it follows `else`. Without `else`, nothing is attempted from that node. A matched branch is final Once a branch matches, the condition never tries another of its branches or its `else` — not when the gateway in that branch declines, and not when it is inactive. What happens next depends on the node around the condition: inside a pool, the pool moves on to its next child; at the top of the tree, the route ends. To fail over within a branch, put a pool inside it, as the example above does. ## Splits A `split` sends each payment down one of its `outputs`, in proportion to each output's `percent`. Use it to compare two setups on live traffic, or to move payments to a new gateway a share at a time: ``` { "type": "split", "outputs": [ { "percent": 20, "then": { "type": "gateway", "gatewayProfileId": "gwp_2mXGTxXIkqv4uSdbm8DJCzvHUIu" } }, { "percent": 80, "then": { "type": "pool", "strategy": "ordered", "children": [ { "type": "gateway", "gatewayProfileId": "gwp_3RTHQUROb9Bt2dJwhLmTGEykKEe" }, { "type": "gateway", "gatewayProfileId": "gwp_2mXGTxXIkqv4uSdbm8DJCzvHUIu" } ] } } ] } ``` About 20% of payments go to the first gateway alone. The other 80% go to the second gateway, and fail over to the first after a decline. A split has 2 to 20 outputs. Each `percent` is a whole number from 1 to 99, and the percentages of one split add up to 100. The output is chosen from the payment, not drawn again each time: * **Authorizing the same payment again sends it down the same output**, as long as the split and its percentages have not changed. * **Each payment is chosen on its own.** A customer's next payment, such as a subscription renewal, may follow a different output. * **The percentages hold across many payments.** Over a handful of payments, the shares can differ noticeably from them. * **Two splits in one tree choose independently.** The chosen output is final A split never sends a payment down a second output — not when a gateway in the chosen output declines, and not when it is inactive. As with a matched branch, what happens next depends on the node around the split. To fail over within an output, put a pool inside it, as the example above does. To fail over across every gateway, use a `weighted` pool instead. ## Pools A `pool` tries its children in an order set by its `strategy`: | Strategy | Order | | ------------ | --------------------------------------------------------------------------------- | | `ordered` | The order the children are written in. | | `roundRobin` | Written order, starting at a position that moves on from one payment to the next. | | `weighted` | A random order, in proportion to `weights`: one weight from 1 to 100 per child. | A `weighted` pool picks its whole order at once, so failover after a decline also follows the weights. To send a fixed share of payments down separate paths, with no failover between them, use a [split](#splits). `maxAttempts` limits how many gateway attempts a pool makes, including those made by nodes inside it. When it is reached, the pool's remaining children are not tried. ## Skipped is not declined A node that was never attempted is **skipped**. A gateway that was attempted and refused the payment **declined**. A pool moves on after either, but they are not the same: | | Skipped | Declined | | ------------------------------------------------- | ---------------------------------------------------------------- | ------------------------------- | | Examples | An inactive gateway profile, a condition with no matching branch | The gateway refused the payment | | Creates a transaction | No | Yes | | Counts toward `maxAttempts` and the attempt limit | No | Yes | | If nothing else is attempted, the route ends as | `not_routed` | `declined` | A gateway profile that is inactive when the payment is authorized is skipped, even though it was active when the version was published. If every gateway a payment can reach is skipped, nothing is attempted. The payment stays `open` and its `latestMessage` says so. ## When card data is unknown ODUS reads the issuer name, issuer country and funding type from the card's BIN. They are not available for every card, and for some saved cards they are filled in later. A field ODUS does not have is **unknown**, and a comparison against an unknown field is neither true nor false. An unknown field never matches A branch whose condition is unknown is not taken, so the payment moves on to the next branch or `else`. This also holds for negative operators: `ne`, `nin` and `not` do not match an unknown field either. A card with no known issuer country is not matched by `{ "field": "card.issuerCountry", "op": "ne", "value": "US" }`, and nothing reports an error. To decide what happens to those cards yourself, test for them with `exists`, which is always true or false: ``` { "not": { "field": "card.issuerCountry", "op": "exists" } } ``` `all` is false as soon as one of its terms is false, and `any` is true as soon as one is true, whatever the others are. Only when the result depends on an unknown term is the whole condition unknown. ODUS may also hold fewer digits of a card's BIN than your prefix has. When the digits it holds agree with your prefix, `startsWith` is unknown, because the card may or may not be in that range. A metadata key the payment does not have is unknown too. ## Rules every route follows These hold across the whole tree, and no node can turn them off: * **One attempt per gateway profile.** A payment is attempted at each gateway profile at most once. A second node naming the same profile is skipped. * **At most 11 attempts.** One authorization makes at most 11 gateway attempts in total. * **No failover after an unknown outcome.** When a gateway gives no answer, it may have charged the card, so the route stops there. * **A pending attempt ends the route.** When an attempt is left waiting on the customer or the gateway, such as for 3D Secure, the route ends at that gateway and does not move on. * **No failover without time for it.** When too little time is left in the request for another attempt, the route stops instead of starting one. The first attempt is always made. Automatic retries under a [retry policy](https://docs.odus.com/docs/guides/subscriptions/retry-policy.md) do not go through the route: each retry uses the gateway its retry step names. ## Limits | Limit | Value | | ---------------------------------------------------------------- | ----- | | Nodes in one version | 100 | | Branches in one `condition` | 20 | | Children in one `pool` | 20 | | Outputs in one `split` | 2–20 | | `percent` on a split's output | 1–99 | | Terms in one branch's condition, `all`, `any` and `not` included | 50 | | Values in one `in`, `nin` or `startsWith` list | 1,000 | | `maxAttempts` on a pool | 1–11 | --- # Routing Card Payments with a Route This guide creates a route, points card payments at it, and shows how to read the path a payment took. For how a route decides, see [How Routes Work](https://docs.odus.com/docs/guides/routing/how-routes-work.md). Routes are managed through the Core API v2 only. ## Prerequisites * Every gateway profile your route will name is active and supports card payments. See [Gateway Profiles](https://docs.odus.com/docs/guides/dashboard/gateway-profiles.md). ## Create the route Send a [`POST /v2/routes`](https://docs.odus.com/docs/api/core/v2/route-controller-create) request with a name: ``` { "name": "Card routing", "description": "Sends Capital One cards to a dedicated gateway" } ``` The response is the route. Keep its `id` for the next steps. A new route has no versions, so `activeVersion` is `null` and it cannot route payments yet: ``` { "id": "rte_2mXGTxXIkqv4uSdbm8DJCzvHUIu", "name": "Card routing", "description": "Sends Capital One cards to a dedicated gateway", "active": true, "activeVersion": null, "createdAt": "2026-10-14T09:00:00Z", "updatedAt": null } ``` ## Publish and activate a version Send the decision tree to [`POST /v2/routes/{id}/versions`](https://docs.odus.com/docs/api/core/v2/route-controller-publish-version). Set `activate` to `true` to make it the active version straight away: ``` { "activate": true, "tree": { "type": "condition", "branches": [ { "when": { "field": "card.issuerName", "op": "contains", "value": "Capital One" }, "then": { "type": "pool", "strategy": "ordered", "children": [ { "type": "gateway", "gatewayProfileId": "gwp_2mXGTxXIkqv4uSdbm8DJCzvHUIu" }, { "type": "gateway", "gatewayProfileId": "gwp_3RTHQUROb9Bt2dJwhLmTGEykKEe" } ] } } ], "else": { "type": "gateway", "gatewayProfileId": "gwp_3RTHQUROb9Bt2dJwhLmTGEykKEe" } } } ``` The response is the new version. It also carries `tree`, exactly the tree you sent, which is left out here: ``` { "id": "rtv_2mXGTxXIkqv4uSdbm8DJCzvHUIu", "route": "rte_2mXGTxXIkqv4uSdbm8DJCzvHUIu", "number": 1, "createdAt": "2026-10-14T09:01:00Z", "updatedAt": null } ``` A tree that names an unknown or inactive gateway profile, or breaks a [limit](https://docs.odus.com/docs/guides/routing/how-routes-work.md#limits), is refused with `400`. Without `activate`, the route keeps its current active version. To activate a version later, or to roll back to an earlier one, send its ID to [`POST /v2/routes/{id}/activate`](https://docs.odus.com/docs/api/core/v2/route-controller-activate): ``` { "version": "rtv_2mXGTxXIkqv4uSdbm8DJCzvHUIu" } ``` The response is the route, with that version as its `activeVersion`. ## Check where a payment would go Before you activate a version, you can see where it would send a payment. Send the payment's facts to [`POST /v2/routes/{id}/evaluation`](https://docs.odus.com/docs/api/core/v2/route-evaluation-controller-evaluate). Nothing is charged or stored. ``` { "version": "rtv_2mXGTxXIkqv4uSdbm8DJCzvHUIu", "facts": { "amount": 1000, "currency": "usd", "initiator": "customer", "card": { "bin": "41472000", "issuerName": "Capital One", "issuerCountry": "US" } } } ``` Send `version` to check a published version, `tree` to check a tree you have not published yet, or neither to check the active version. Leave out any card field you do not know. A condition on a missing field cannot be decided, so it does not match, as it would for a real payment. `attempts` lists the gateway profiles the payment would be sent to, in order. Each one is tried only if every one before it declined. `steps` has the same shape as the steps of a [route execution](#read-the-path-a-payment-took), without the transaction and timestamps: ``` { "route": "rte_2mXGTxXIkqv4uSdbm8DJCzvHUIu", "routeVersion": "rtv_2mXGTxXIkqv4uSdbm8DJCzvHUIu", "attempts": [ { "attempt": 1, "gatewayProfile": "gwp_2mXGTxXIkqv4uSdbm8DJCzvHUIu", "route": "rte_2mXGTxXIkqv4uSdbm8DJCzvHUIu", "path": "$.branches[0].then.children[0]" }, { "attempt": 2, "gatewayProfile": "gwp_3RTHQUROb9Bt2dJwhLmTGEykKEe", "route": "rte_2mXGTxXIkqv4uSdbm8DJCzvHUIu", "path": "$.branches[0].then.children[1]" } ], "steps": [ { "path": "$", "result": "matched", "branch": "0" }, { "path": "$.branches[0].then.children[0]", "result": "attempted", "gatewayProfile": "gwp_2mXGTxXIkqv4uSdbm8DJCzvHUIu", "attempt": 1 }, { "path": "$.branches[0].then.children[1]", "result": "attempted", "gatewayProfile": "gwp_3RTHQUROb9Bt2dJwhLmTGEykKEe", "attempt": 2 } ] } ``` The example is shortened: every step carries every field, `null` where it does not apply. Gateway profiles are taken as they are now, so an inactive one is `skipped`. A `roundRobin` pool starts where your next payment through that version would start it. A `weighted` pool draws a new random order on every request, as each payment does. ## Save an unfinished edit as a draft To keep an edit you have not finished, save it as the route's draft with [`POST /v2/routes/{id}/draft`](https://docs.odus.com/docs/api/core/v2/route-controller-save-draft). The tree can be incomplete, such as a branch with no `then` yet or a `gateway` with no `gatewayProfileId`: ``` { "tree": { "type": "condition", "branches": [{ "when": { "field": "card.issuerCountry", "op": "eq", "value": "US" } }], "else": { "type": "gateway" } } } ``` The response is the draft, with the tree exactly as you sent it: ``` { "id": "rtd_2mXGTxXIkqv4uSdbm8DJCzvHUIu", "route": "rte_2mXGTxXIkqv4uSdbm8DJCzvHUIu", "tree": { "type": "condition", "branches": [{ "when": { "field": "card.issuerCountry", "op": "eq", "value": "US" } }], "else": { "type": "gateway" } }, "createdAt": "2026-10-14T09:05:00Z", "updatedAt": null } ``` A route has at most one draft. Saving again replaces its whole tree. A draft must still be shaped like a tree you could publish: a node type, key or `field` that a route does not have is refused with `400`, and so is a draft over the [limits](https://docs.odus.com/docs/guides/routing/how-routes-work.md#limits). Read it back with [`GET /v2/routes/{id}/draft`](https://docs.odus.com/docs/api/core/v2/route-controller-find-draft), which answers `404` when the route has no draft. Discard it with [`POST /v2/routes/{id}/draft/delete`](https://docs.odus.com/docs/api/core/v2/route-controller-delete-draft). A draft is not a version. No payment is routed by it, and it cannot be activated. When it is finished, publish it with [`POST /v2/routes/{id}/draft/publish`](https://docs.odus.com/docs/api/core/v2/route-controller-publish-draft). The body takes only `activate`, which works as it does for publishing a version: ``` { "activate": true } ``` The draft's tree goes through the same checks as a tree sent to `POST /v2/routes/{id}/versions`. If it passes, the response is the new version and the draft is deleted. If it fails, the response is `400`, nothing is published, and the draft stays as it was. ## Point card payments at the route Set the card's `mode` to `route` and name the route. You can do this for one payment, or for every payment by default. **For one payment**, send it in `paymentMethodOptions` when you [create](https://docs.odus.com/docs/api/core/v2/payment-controller-create) or [update](https://docs.odus.com/docs/api/core/v2/payment-controller-update) the payment: ``` { "amount": 1000, "currency": "usd", "paymentMethodOptions": { "card": { "mode": "route", "route": "rte_2mXGTxXIkqv4uSdbm8DJCzvHUIu" } } } ``` **For every payment**, set it in `defaultPaymentMethodOptions` with [`POST /v2/merchant-config`](https://docs.odus.com/docs/api/core/v2/merchant-config-controller-set), or choose the route on the dashboard's [Merchant configuration](https://docs.odus.com/docs/guides/dashboard/merchant-configuration.md#default-route) page. A payment that does not set its own card options uses it: ``` { "defaultPaymentMethodOptions": { "card": { "mode": "route", "route": "rte_2mXGTxXIkqv4uSdbm8DJCzvHUIu" } } } ``` In the response, the card options name the route. Shortened: ``` { "paymentMethodOptions": { "card": { "mode": "route", "route": { "id": "rte_2mXGTxXIkqv4uSdbm8DJCzvHUIu", "name": "Card routing", "description": "Sends Capital One cards to a dedicated gateway", "active": true } } } } ``` The merchant configuration names it the same way, under `defaultPaymentMethodOptions`. Both requests are refused with `400` unless the route is active and has an active version. Deactivating a route stops its payments You can deactivate a route with `"active": false` on [`POST /v2/routes/{id}`](https://docs.odus.com/docs/api/core/v2/route-controller-update) while payments or your default still point at it. Nothing is refused then, and new payments that take the route from your default are still created. At authorization, no gateway is attempted: the payment stays `open`, and its `latestMessage` reads "The payment's route is inactive or has no active version, so no gateway was attempted." ## Read the path a payment took Send [`GET /v2/payments/{id}/route-executions`](https://docs.odus.com/docs/api/core/v2/payment-controller-find-all-route-executions-paginated). Each authorization through a route adds one execution. When the route is inactive or has no active version at authorization, no execution is added: the payment's `latestMessage` explains it instead. It records the version that ran, the `facts` its conditions read, and a `steps` entry for every node the route reached, in order: ``` { "data": [ { "id": "rtx_2mXGTxXIkqv4uSdbm8DJCzvHUIu", "payment": "pay_2mXGTxXIkqv4uSdbm8DJCzvHUIu", "routeVersion": { "id": "rtv_2mXGTxXIkqv4uSdbm8DJCzvHUIu", "route": "rte_2mXGTxXIkqv4uSdbm8DJCzvHUIu", "number": 1 }, "status": "completed", "outcome": "authorized", "facts": { "amount": 1000, "currency": "usd", "initiator": "customer", "card": { "brand": "visa", "bin": "41472000", "issuerName": "Capital One", "issuerCountry": "US", "fundingType": "credit" }, "metadata": null }, "steps": [ { "position": 0, "path": "$", "result": "matched", "branch": "0" }, { "position": 1, "path": "$.branches[0].then.children[0]", "result": "attempted", "attempt": 1, "gatewayProfile": "gwp_2mXGTxXIkqv4uSdbm8DJCzvHUIu", "transaction": "tx_2mXGTxXIkqv4uSdbm8DJCzvHUIu", "outcome": "declined" }, { "position": 2, "path": "$.branches[0].then.children[1]", "result": "attempted", "attempt": 2, "gatewayProfile": "gwp_3RTHQUROb9Bt2dJwhLmTGEykKEe", "transaction": "tx_3RTHQUROb9Bt2dJwhLmTGEykKEe", "outcome": "authorized" } ] } ], "pagination": { "mode": "cursor", "pageSize": 10, "hasNextPage": false, "hasPreviousPage": false, "nextCursor": null, "prevCursor": null } } ``` The example is shortened. Every object also carries its timestamps, and every step carries every field, `null` where it does not apply. `path` locates the node in the version's tree, which you can read with [`GET /v2/routes/{id}/versions/{version}`](https://docs.odus.com/docs/api/core/v2/route-controller-find-version). Each step's `result` says what happened at that node: | `result` | Meaning | | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `matched` | The condition followed `branch`: its index, or `else`. For a split, `branch` is the index of the output the payment took. | | `no_match` | `branch` did not match. | | `unknown_fact` | `branch` could not be decided. `unknownFields` lists the fields the payment lacked, and `unsupportedTerms` the parts of the condition ODUS could not evaluate. | | `attempted` | The payment was sent to `gatewayProfile`. `outcome` is the gateway's answer. | | `skipped` | The node was passed over, for the `reason` given, such as `gateway_inactive`. | | `withheld` | The route stopped before trying `gatewayProfile`, for the `reason` given. | New `reason` values may be added. Branch on `result`, and treat a `reason` you do not recognise as an explanation only. ### Why did a rule not fire? Find the condition's steps and check the branch's `result`: * **`no_match`**: the payment's facts did not meet the condition. Compare them with `facts`. * **`unknown_fact`**: the condition needed a field ODUS did not have for this card, listed in `unknownFields`, or held a term ODUS could not evaluate, listed in `unsupportedTerms`. See [When card data is unknown](https://docs.odus.com/docs/guides/routing/how-routes-work.md#when-card-data-is-unknown) for how to route those cards on purpose. When the route attempted nothing, the execution's `outcome` is `not_routed`. Look for the `skipped` steps that explain why. --- # Subscriptions Learn about ODUS subscriptions and how to manage them using the API. * [Subscription Lifecycle](https://docs.odus.com/docs/guides/subscriptions/subscription-lifecycle.md) * [Creating Subscriptions](https://docs.odus.com/docs/guides/subscriptions/creating-subscriptions.md) * [Updating Plans](https://docs.odus.com/docs/guides/subscriptions/updating-plans.md) * [Cancelling and Resuming](https://docs.odus.com/docs/guides/subscriptions/canceling-and-resuming.md) * [Payment Bundling](https://docs.odus.com/docs/guides/subscriptions/payment-bundling.md) * [Retry Policy](https://docs.odus.com/docs/guides/subscriptions/retry-policy.md) --- # Cancelling and Resuming ODUS gives you three ways to cancel a subscription: immediately, at the end of the current billing period, or at the end of the **next** one — letting the upcoming renewal charge go through first. Either scheduled option can be reversed by resuming the subscription before it takes effect. ## Prerequisites You need the ID of an `active` subscription. Only active subscriptions can be cancelled or resumed. ## Cancelling a Subscription Send a [`POST /v2/subscriptions/{id}/cancel`](https://docs.odus.com/docs/api/core/v2/subscription-controller-cancel) request and pick the boundary with `cancelAt`: | `cancelAt` | Takes effect | Further charges | | ---------------------- | --------------------- | -------------------------------- | | `immediately` | Right away | None; open invoices are voided | | `end_of_current_cycle` | At `currentPeriodEnd` | None | | `end_of_next_cycle` | One renewal later | One more renewal charge is taken | `cancelAt` defaults to `end_of_current_cycle`. Moving from v1: send `cancelAt`, not `cancelImmediately` `cancelImmediately` is not part of v2. Where v1 sent `{ "cancelImmediately": true }`, send `{ "cancelAt": "immediately" }`. If you remove `cancelImmediately` without adding `cancelAt`, the request gets the default, `end_of_current_cycle`. It succeeds without an error, and the subscription keeps billing until the end of the current cycle. ### Immediate cancellation To cancel a subscription right away: ``` { "cancelAt": "immediately" } ``` ODUS immediately: 1. **Voids all open invoices** belonging to the subscription. No further charges are attempted. 2. Sets the subscription `status` to `cancelled`. The customer loses access as soon as the request completes. This action cannot be undone. To re-subscribe the customer, you must create a new subscription. ### End-of-period cancellation To let the customer keep access until their current billing period ends: ``` { "cancelAt": "end_of_current_cycle" } ``` ODUS sets `cancelAtPeriodEnd: true` on the subscription and records the booking in `cancelAtCycle`. The subscription `status` remains `active`, and the customer continues to have access until `currentPeriodEnd`. When that date arrives, ODUS cancels the subscription automatically without collecting any additional charge. The response shows the pending cancellation state: ``` { "id": "sub_2Q9fNQAs77Fhue9itgVFbUzq8NV", "status": "active", "cancelAtPeriodEnd": true, "currentCycle": 4, "cancelAtCycle": 4, "currentPeriodEnd": "2024-03-09T00:00:00.000Z" } ``` ### Cancellation after one more renewal Some cancellation policies have a notice window: a request made close to the renewal date should let that renewal complete and take effect afterwards. Send: ``` { "cancelAt": "end_of_next_cycle" } ``` ODUS books the cancellation against the **next** cycle. Because the current cycle is unaffected, `cancelAtPeriodEnd` stays `false` and the upcoming renewal is charged exactly as it would have been: ``` { "id": "sub_2Q9fNQAs77Fhue9itgVFbUzq8NV", "status": "active", "cancelAtPeriodEnd": false, "currentCycle": 4, "cancelAtCycle": 5, "currentPeriodEnd": "2024-03-09T00:00:00.000Z" } ``` Once that renewal is billed and the subscription moves into cycle 5, `cancelAtPeriodEnd` flips to `true` on its own and the subscription cancels at the new `currentPeriodEnd`. You do not need to run a timer against the renewal date yourself. The booking is stored as a **cycle number**, not a timestamp, so it stays correct even when the period boundary moves — for example when a failed renewal delays it through retries. Plan updates while a cancellation is booked A subscription with a pending cancellation cannot have its plan or next billing date updated; the request returns a `400`. [Resume](#resuming-a-subscription) it first, apply the update, then cancel again. If the intervening charge fails The cancellation still fires at the end of that cycle. A failed renewal that starts [retries](https://docs.odus.com/docs/guides/subscriptions/subscription-lifecycle.md) advances the subscription into the booked cycle just as a successful one does, so the booking becomes due at that cycle's `currentPeriodEnd` either way. If the retries are exhausted, the subscription is cancelled at that point instead — sooner than booked, never later. If billing stops for another reason before the renewal is even attempted, the booking is brought forward onto the current period, so the cancellation still happens rather than waiting for a renewal that will never come. For the same reason, `end_of_next_cycle` is rejected with a `400` on a subscription whose automatic billing has already stopped — use `end_of_current_cycle` there. One booking at a time A subscription that is already scheduled for cancellation — at either boundary — cannot be scheduled again; the request returns a `400`. To move a booking from one boundary to the other, [resume](#resuming-a-subscription) the subscription first and then cancel it again. Cancelling **immediately** is always allowed, since it is terminal either way. ### Cancelling from the Dashboard 1. Open the subscription detail page. 2. Click **Cancel Subscription**. 3. In the dialog, use the **Cancel immediately** toggle to choose between immediate and end-of-period cancellation. 4. Click **Cancel** to confirm. The Dashboard covers the immediate and end-of-current-period options; `end_of_next_cycle` is available through the API. ## Resuming a Subscription If you have scheduled a subscription for cancellation — at either boundary — and it has not yet taken effect, you can reverse the decision. Send a [`POST /v2/subscriptions/{id}/resume`](https://docs.odus.com/docs/api/core/v2/subscription-controller-resume) request — no request body is required: ``` POST /v2/subscriptions/sub_2Q9fNQAs77Fhue9itgVFbUzq8NV/resume ``` ODUS clears `cancelAtCycle` and sets `cancelAtPeriodEnd` back to `false`. The subscription returns to its normal billing state and will renew at `currentPeriodEnd` as usual. ### Resuming from the Dashboard 1. Open the subscription detail page. 2. Click **Resume Subscription**. 3. Confirm in the dialog. ### Edge case: already cancelled subscriptions Resume only works when a subscription is `active` with a pending cancellation — that is, with a non-null `cancelAtCycle`. A subscription whose `status` is already `cancelled` cannot be resumed. You must create a new subscription for the customer instead. ## Reactivating a Subscription Whose Billing Stopped A subscription can stay `active` after its billing has stopped: `autoBillingEnabled` is `false`, and no renewal will be attempted. Once the customer can be charged again, restart billing with a [`POST /v2/subscriptions/{id}/reactivate`](https://docs.odus.com/docs/api/core/v2/subscription-controller-reactivate) request. No request body is required, and your key needs `subscription:force_bill`. ``` POST /v2/subscriptions/sub_2Q9fNQAs77Fhue9itgVFbUzq8NV/reactivate ``` ODUS then: 1. Starts a new billing period now. `currentPeriodEnd` is set to the time of your request, and `upcomingInvoice` covers one billing interval from then. 2. Sets `autoBillingEnabled` to `true`. 3. Takes the renewal charge within a few minutes, like any other renewal. If the charge fails, the usual retry rules apply. Missed periods are not charged The customer is charged once, for the new period. Periods that went unpaid while billing was stopped are not charged, and the renewal date moves to the day you reactivate. Reactivating takes a charge. Check that the customer still wants the subscription and has a payment method that can be charged before you send the request. If a customer bought several subscriptions together and [payment bundling](https://docs.odus.com/docs/guides/subscriptions/payment-bundling.md) is enabled, reactivate them one right after another so they can be charged in a single payment. The request is refused with `400` and `type` `invalid_state_error` when the subscription: * is not `active` * is still billing (`autoBillingEnabled` is `true`) * is scheduled for cancellation. [Resume it](#resuming-a-subscription) first. * has an `open` invoice that can still be paid, for example while a payment is being retried. Wait for that invoice to be paid or voided. * has a paid billing period that has not ended, or had its latest renewal paid by a retry. The customer has already paid, and billing resumes on its own. * is still processing the outcome of its latest renewal. Try again in a few minutes. * is in a billing period that has not started yet. Reactivation is available in v2 only. ## Related Guides * [Subscription Lifecycle](https://docs.odus.com/docs/guides/subscriptions/subscription-lifecycle.md) — how subscription statuses, billing cycles, and invoices work together * [Updating Plans](https://docs.odus.com/docs/guides/subscriptions/updating-plans.md) — how to change a subscription's pricing plan --- # Creating Subscriptions A subscription is not created by calling a dedicated endpoint. Instead, ODUS creates a subscription automatically when a payment that contains a recurring price succeeds. This guide explains how to set up that payment so a subscription is activated for your customer. ## Prerequisites * **A recurring price must exist.** A subscription can only be started from a price with `type: recurring`. If you have not set one up yet, follow [Creating products and prices](https://docs.odus.com/docs/guides/products/creating-products-and-prices.md) first. * **A customer.** The subscription is tied to a customer record. A returning customer already has one. A new customer gets one when they pay through the Checkout SDK — v2 has no endpoint to create a customer yourself. See [Managing customers](https://docs.odus.com/docs/guides/customers/managing-customers.md). ## Step 1: Create a payment with a recurring price Send a [`POST /v2/payments`](https://docs.odus.com/docs/api/core/v2/payment-controller-create) request and include the recurring price as a line item in `cart.items`. For a returning customer, set `customer` to their ID. For a new customer, leave `customer` out: the Checkout SDK collects their details when they pay, and the subscription is created for the customer record made from them. Creating a payment for a subscription ``` { "currency": "usd", "initiator": "customer", "customer": "cus_2R0uWz4tu9wxTMBXxj9CJfSE9vV", "cart": { "items": [ { "price": "price_2R4e2xDqbww8ewmkIYWE1A6Zcfp", "quantity": 1 } ] }, "paymentMethodOptions": { "card": { "mode": "cascade", "cascade": "cas_abc123" } } } ``` The `currency` must match the recurring price's currency. Set `initiator` to `customer` for sign-up flows. The `paymentMethodOptions` specify the gateway or cascade to use. See the [`POST /v2/payments`](https://docs.odus.com/docs/api/core/v2/payment-controller-create) reference for the full parameter list. important The `paymentMethodOptions` you provide here are stored as the subscription's **billing settings**. Every future renewal charge uses the same payment method type, gateway profile, and capture configuration. Set these values carefully. If the customer already has a saved payment method, you can charge it directly without involving the Checkout SDK: Creating a payment using a saved payment method ``` { "currency": "usd", "initiator": "customer", "customer": "cus_2R0uWz4tu9wxTMBXxj9CJfSE9vV", "paymentMethod": "pm_2S1xKp8qF0lA3mNvRt7czWuYhBd", "cart": { "items": [ { "price": "price_2R4e2xDqbww8ewmkIYWE1A6Zcfp", "quantity": 1 } ] }, "paymentMethodOptions": { "card": { "mode": "cascade", "cascade": "cas_abc123" } } } ``` ## Step 2: Authorize the payment After creating the payment, authorize it by sending a [`POST /v2/payments/{id}/authorize`](https://docs.odus.com/docs/api/core/v2/payment-controller-authorize) request. If you are collecting payment details from a new customer (for example, a new card), use the [Checkout SDK](https://docs.odus.com/docs/guides/checkout/drop-in/getting-started.md) to securely collect and submit the payment information. If you are charging a saved payment method directly via the API, include a valid `returnUrl` in case 3DS authentication is required: ``` { "context": { "returnUrl": "https://example.com/return" } } ``` For full details on both authorization paths, see [Making Payments](https://docs.odus.com/docs/guides/payments/making-payments.md). ## What happens after the payment succeeds After the payment succeeds, ODUS automatically: 1. Creates a subscription in `active` status, linked to the customer and the recurring price. 2. Creates a `setup` invoice of type `setup`, already marked as `paid`, linked to the payment. 3. Calculates the first billing period (`currentPeriodStart` and `currentPeriodEnd`) from the price's recurring schedule. 4. Schedules the first automatic renewal. The subscription's `startDate` is set to the moment the subscription was created. It cannot be set in advance through the API. ### When the subscription ID is available ODUS creates the subscription **after** the payment's status changes to `succeeded`, not at the same moment. Each recurring line item in the payment that starts the subscription gets its own subscription, and its ID is then written to that item's `cart.items[].subscription`. This usually happens within a second, but it can take longer. So a payment that is already `succeeded` can still show `subscription: null` on a recurring line item, both in the response that reported the success and in [`GET /v2/payments/{id}`](https://docs.odus.com/docs/api/core/v2/payment-controller-find-one). Only the payment that starts a subscription has its line items linked this way. A renewal payment that ODUS creates for an existing subscription also carries the recurring price in `cart.items`, but its `subscription` stays `null`. Don't treat a missing subscription as a failure on its own A `succeeded` payment with `cart.items[].subscription` still `null` is not by itself an error. Don't create a subscription yourself, retry the payment, or refund it because the field is empty. To get the subscription ID reliably: * **Listen for the [`subscription.active`](https://docs.odus.com/docs/api/core/v2/webhook-subscription-active) webhook.** It is sent once the subscription exists. Match it to the payment with its `setupLineItem` field, which is the ID of the line item in `cart.items[]` that started the subscription. * **Or read the payment again.** If you don't use webhooks, call `GET /v2/payments/{id}` again with increasing delays until every recurring line item has a `subscription`, and stop at a deadline your application sets. If a recurring line item still has no subscription an hour after the payment succeeded, contact ODUS support with the payment ID. Once set, `cart.items[].subscription` does not change. You can retrieve the new subscription by calling [`GET /v2/subscriptions/{id}`](https://docs.odus.com/docs/api/core/v2/subscription-controller-find-one). The `setupInvoice` field on the subscription holds the ID of the first invoice. You can retrieve that invoice using [`GET /v2/invoices/{id}`](https://docs.odus.com/docs/api/core/v2/invoice-controller-find-one) — it includes the associated payment and transaction details. Example response (partial) ``` { "id": "sub_2Q9fNQAs77Fhue9itgVFbUzq8NV", "status": "active", "autoBillingEnabled": true, "startDate": "2026-03-31T09:00:00.000Z", "currentPeriodStart": "2026-03-31T09:00:00.000Z", "currentPeriodEnd": "2026-04-30T09:00:00.000Z", "currentCycle": 1, "setupInvoice": "inv_2Q8mig1bCDKh4UrxAX0o3AHqQi1", "billingSettings": { "paymentMethodType": "card", "gatewayProfile": "gwp_2QUGPTQo8As1cNIvgKlSFDxjJDd", "captureMethod": "automatic" } } ``` ## Viewing the subscription in the Dashboard After the payment succeeds, the new subscription appears in **Subscriptions** in the Dashboard. You can view its status, current billing period, associated invoices, and billing settings from the subscription detail page. ## Next steps To understand what happens after the subscription is created — how it renews, how failures are handled, and what states it moves through — read the [Subscription Lifecycle](https://docs.odus.com/docs/guides/subscriptions/subscription-lifecycle.md) guide. --- # Payment Bundling When a customer has multiple active subscriptions, ODUS can charge them in a single payment instead of separate charges per subscription. This reduces transaction fees, simplifies billing statements for your customers, and can improve payment success rates by reducing the number of charges. ## How It Works Every minute, ODUS checks for subscriptions that are due for renewal. When bundling is enabled, subscriptions belonging to the same customer are grouped together if they meet the bundling criteria. The group is charged as one payment covering all subscriptions in the group. ## Enabling Bundling Bundling is disabled by default. Enable it by sending a [`POST /v2/merchant-config`](https://docs.odus.com/docs/api/core/v2/merchant-config-controller-set) request with the following field: ``` { "enableRecurringPaymentBundling": true } ``` To disable bundling, set the field to `false`. Bundles that have already been scheduled will still be charged as a single payment. Only billing runs that start after the change will charge subscriptions individually. ## Bundling Criteria Two subscriptions can be bundled together only when all of the following match: | Criterion | Description | | ------------------- | -------------------------------------------------------------------- | | Customer | Both subscriptions belong to the same customer | | Gateway profile | Both use the same payment gateway profile | | Payment method type | Both use the same payment method type (e.g. card) | | Capture method | Both have the same capture method | | Capture delay | Both have the same capture delay setting | | Currency | Both invoices are in the same currency | | Renewal window | The later subscription renews within **24 hours** of the earlier one | If any criterion does not match, the subscriptions are charged separately. ## Bundle Formation Within each billing run, subscriptions are sorted by renewal date (earliest first). The subscription due earliest becomes the **primary** of its bundle. Any other subscription due within 24 hours that shares all criteria with the primary is added to the bundle as a **bundled** subscription. Each bundle contains at most **5 subscriptions** (1 primary + 4 bundled). If a customer has more eligible subscriptions, they are split into multiple bundles, each charged separately. Each subscription can only appear in one bundle per billing run. Example A customer has three active subscriptions: * **Sub A** — renews at 08:00, currency USD, gateway G1 * **Sub B** — renews at 14:00, currency USD, gateway G1 * **Sub C** — renews at 08:30, currency EUR, gateway G1 Sub A is the primary. Sub B is within the 24-hour window and shares all criteria, so it is bundled with Sub A. Sub C is within the window, but uses a different currency, so it is charged separately. Result: one payment for Sub A + Sub B, one payment for Sub C. ## Payment and Invoices A single payment is created for the combined amount of all subscriptions in the bundle. Each subscription in the bundle has its own invoice, and all invoices are attached to that one payment. After a successful charge, every subscription in the bundle advances to its next billing cycle. ## Failure Behaviour Bundled payments follow the same failure behaviour as individual subscription payments: 1. If the initial charge fails, ODUS starts a payment retry. 2. If the retry also fails, **all subscriptions in the bundle are cancelled immediately.** warning A payment failure cancels every subscription in the bundle, not only the one that could not be charged. Make sure your customers have a valid default payment method to minimise the risk of losing access to multiple subscriptions at once. ## FAQ ### I believe the subscriptions should have been bundled but they were charged separately. Why? Check the bundling criteria above and cross-reference with the affected subscription details in the dashboard. Common reasons for subscriptions not being bundled include: 1. The subscriptions use different payment method types. For example, one subscription is set to pay by card while another uses PayPal. 2. If you're using cascades, it's possible that one of the subscriptions has a different gateway profile chosen by the cascade rules. For example, you're using Gateway A as the default, but fall back to Gateway B if the charge fails, and one subscription successfully charges on Gateway A while the other fails and falls back to Gateway B - in this case, the subscriptions won't have a matching gateway profile and won't be bundled together. ### Can the bundling criteria be customised? No, the criteria are fixed and cannot be customised. This is needed to ensure that the behaviour is predictable and consistent for all merchants using bundling. The criteria are designed to balance the benefits of bundling (reduced fees, simplified billing) with the need to ensure that bundled subscriptions are compatible for charging together. --- # Related Subscriptions A single checkout often creates more than one subscription — a paid plan plus a free add-on, or a plan followed moments later by an upsell. Each of those is a subscription in its own right, with its own billing cycle and its own cancellation. That independence is what makes a **zero-amount subscription** easy to lose track of. When the paid subscription is cancelled, the free one keeps renewing at $0.00 for as long as the customer exists, quietly producing recurring orders nobody is watching. The **Related subscriptions** section on a subscription's detail page shows the whole group, so a free add-on is visible from the subscription it was bought with. Each row opens that subscription's own detail page, where you can cancel it. note Grouping is a read-only view. ODUS never cancels a related subscription on your behalf; every cancellation is an explicit action you take, on that subscription's own page. ## How subscriptions are grouped Two subscriptions belong to the same group when they are held by the **same customer** and either of the following is true: | Rule | Always on? | What it catches | | ---------------------------------------------------- | -------------------------- | -------------------------------------------------------------------- | | They share the same **setup payment** | Yes | Everything bought in one checkout, including free items | | They were **created within N minutes** of each other | Only when you set a window | An upsell, which is a second checkout and therefore a second payment | The window is measured from the subscription you are looking at, in both directions, and is never chained: a subscription 5 minutes after yours is in the group, but one 5 minutes after *that* is not, unless it also falls inside your own window. Because the group is worked out when you open the page, changing the window re-groups your existing subscriptions straight away. Nothing is stored, and nothing needs backfilling. ## Setting the grouping window Go to **Settings > General** and set **Subscription grouping window** at the bottom of the **Dashboard settings** section. The value is a whole number of minutes between `0` and `1440` (24 hours). * `0` — the default. The time window is off; subscriptions are grouped only when they share a setup payment. * Any other value — subscriptions created that many minutes apart are grouped as well. A few minutes is usually enough to catch a post-purchase upsell flow. A wide window on a customer who buys from you often will start grouping unrelated purchases, so start small. The same value can be read and written through [`GET /v2/merchant-config`](https://docs.odus.com/docs/api/core/v2/merchant-config-controller-get) and [`POST /v2/merchant-config`](https://docs.odus.com/docs/api/core/v2/merchant-config-controller-set) as `relatedSubscriptionWindowMinutes`. ## Reading the section Open any subscription from **Subscriptions** and scroll to **Related subscriptions**, at the bottom of the page below **Invoices**. Each row shows: * **Subscription** — the price and product the member bills on * **Status** — the same status chip used across the Dashboard. Cancelled members stay in the group, dimmed, so the history of the purchase is complete * **Amount** — the recurring charge each billing cycle. A free add-on shows as `$0.00` * **Created** — how far the member's creation sits from the subscription you are viewing, for example *Same time* for a bundled item or *7 min later* for an upsell **Click any row** to open that subscription's own detail page, where you can cancel it, change its price, or read its invoices. The section itself only shows the group — it changes nothing. If nothing was bought alongside, the section says so. The message reads the same whether or not a grouping window is set — widen or enable the window under **Settings → General** if you expected a member that was created moments apart. ## Through the API [`GET /v2/subscriptions/{id}/related`](https://docs.odus.com/docs/api/core/v2/subscription-controller-find-related) returns the same group, along with the `windowMinutes` the lookup applied so you can tell an empty group from a disabled window: ``` { "windowMinutes": 15, "subscriptions": [ { "id": "sub_2QC7PqoUeOxQEWscJJt6B3PkvyL", "status": "active", "cancelAtPeriodEnd": false, "createdAt": "2026-03-01T10:00:00.000Z", "createdAtDeltaSeconds": 0, "amount": 0, "currency": "usd", "priceName": "Free bonus guide", "productName": "Bonus guide", "matchedOn": "setup_payment" } ] } ``` `matchedOn` tells you which rule put the subscription in the group — `setup_payment` for the exact match, `creation_window` for the time-based one. Members are returned oldest first, and the subscription you asked about is never one of them. ## Related * [Cancelling and Resuming](https://docs.odus.com/docs/guides/subscriptions/canceling-and-resuming.md) — what each cancellation option does * [Merchant Configuration](https://docs.odus.com/docs/guides/dashboard/merchant-configuration.md) — every account-wide default --- # Configure a Payment Retry Policy When a subscription renewal charge fails, ODUS automatically retries the charge according to the merchant's retry policy (also called a **recycle profile**). A retry policy defines a schedule of retry attempts, each with configurable delays and gateway options. ## Prerequisites * A secret API key with the `retry_policy:update` permission to set the policy, and `retry_policy:read` to read it. * The IDs of any gateway profiles you want to pin specific retry steps to. ## How a Retry Policy Works A retry policy contains a list of **steps**. Each step represents one retry attempt. Steps are executed in order of their `position` value (lowest first). When a renewal payment fails, ODUS works through the steps one by one, waiting the configured `retryDelay` (in days) between each attempt. Each step can either retry on the original gateway (`useInitialGateway: true`) or target a specific `gatewayProfile`. You can also set a `priceReductionPercentage` to lower the charge amount for that attempt. See the [`POST /v2/retry-policy`](https://docs.odus.com/docs/api/core/v2/retry-policy-controller-set) reference for the full parameter list. note Gateway selection only applies to card payments. For other payment methods such as PayPal or Apple Pay, ODUS always uses the initial gateway regardless of the step configuration. ### Payments that never reached a decision Some failures mean the request stopped before the issuer could approve or decline it. These are the `authentication_unavailable` and `processor_rate_limited` [error codes](https://docs.odus.com/docs/guides/payments/error-codes.md). ODUS resends these payments before your retry policy starts: * The payment is resent **unchanged**. It keeps the same amount, gateway and payment method. No step's `priceReductionPercentage` or `gatewayProfile` applies. * ODUS resends it up to **three times**, about 1, 5 and 15 minutes apart. No resend is scheduled more than **30 minutes** after the first failure. * These resends are not policy steps. While a resend is pending, `retry.attempts` does not change, and `retry.nextAt` shows when it is due. A resend that succeeds counts as one retry attempt, like a successful step, so `isRecovered` is `true`. * If a resend succeeds, the payment succeeds as it would after any retry. If a resend fails for another reason, ODUS handles it like any other failed retry. If all three resends fail, ODUS continues with the policy step that was next. In both cases none of your steps is used up by the resends. The same happens when a policy step fails with one of these codes: ODUS resends that step unchanged and then moves on to the following step. Other codes with the `retry_immediately` strategy, such as `processor_timeout`, are never resent this way. Those payments may already have been processed, so ODUS waits for the policy's `retryDelay`. ## Your account has one retry policy Your account has at most one retry policy, and ODUS applies it to every subscription renewal that fails. Until you set one, a failed renewal is not retried. `GET /v2/retry-policy` and `POST /v2/retry-policy` both act on that one policy, so neither takes an ID. ## View the Retry Policy ``` GET /v2/retry-policy ``` The response includes the policy's `title`, `isEnabled`, and the full list of `steps`. If you have not set a policy yet, the request answers `404`. Treat that as "no retry policy configured", not as a failure. See the [`GET /v2/retry-policy`](https://docs.odus.com/docs/api/core/v2/retry-policy-controller-get) reference for the full response schema. ## Set the Retry Policy Send a [`POST /v2/retry-policy`](https://docs.odus.com/docs/api/core/v2/retry-policy-controller-set) request with a `title` and at least one step. The same request creates the policy if you have none and replaces it if you do: ``` { "title": "Standard recovery — 3 attempts", "steps": [ { "position": 1, "retryDelay": 3, "useInitialGateway": true }, { "position": 2, "retryDelay": 7, "useInitialGateway": true, "priceReductionPercentage": 10 }, { "position": 3, "retryDelay": 14, "useInitialGateway": false, "gatewayProfile": "gwp_2QUGPTQo8As1cNIvgKlSFDxjJDd" } ] } ``` Send the whole policy every time A request replaces the policy, including every step. To change one step, send the `title` and the complete list of `steps` with that step changed. Steps you leave out are removed. ## Effect on the Subscription Lifecycle When a subscription renewal fails, ODUS sets `autoBillingEnabled: false` with `autoBillingDisabledReason: latest_invoice_retrying` and begins executing the retry steps. * If a retry **succeeds**, `autoBillingEnabled` is restored to `true` and normal billing resumes. * If all retry steps are **exhausted**, ODUS voids the invoice and cancels the subscription. You receive a `subscription.cancelled` event. For the full picture of subscription states and how retries fit the billing lifecycle, see [Subscription Lifecycle](https://docs.odus.com/docs/guides/subscriptions/subscription-lifecycle.md). ## Configure via the Dashboard 1. Go to **Settings** → **Recycle Billing**. 2. If no profile exists yet, click **Add**. 3. Enter a **Title** for the profile. 4. Under **Billing rules**, click **New rule** to add a retry step. 5. For each rule, configure: * **Wait period** — number of days to wait before this attempt * **Price reduction (%)** — optional percentage to reduce the charge amount * **Gateway** — select **Initial Gateway** to retry on the original gateway, or choose a specific gateway profile from the list 6. Drag rules to reorder them. The topmost rule is executed first. 7. Click **Add** (or **Update** if editing an existing profile) to save. A profile can have up to 12 rules. --- # Subscription Lifecycle A subscription in ODUS is a recurring billing agreement between a merchant and a customer for a specific product and price. Once created, ODUS automatically charges the customer at each renewal date according to the price's recurring schedule — no further action is required from your backend. This page explains how subscriptions work conceptually: what states they move through, how billing cycles operate, and how subscriptions relate to invoices and payments. ## What a Subscription Represents A subscription ties together three things: * A **customer** — who is being billed * A **price** — what they are being billed for, and how often * **Billing settings** — which payment method type, gateway profile, and capture configuration to use for recurring charges When a subscription is created, ODUS records a `startDate`, an initial `currentPeriodStart` and `currentPeriodEnd`, and a `currentCycle` counter (starting at 1). These values advance automatically with each successful billing cycle. ## Subscription Statuses The `status` field on a subscription has two values: `active` and `cancelled`. However, what a subscription is *doing* at any point in time depends on several fields together. | State | `status` | `cancelAtCycle` | `cancelAtPeriodEnd` | `autoBillingEnabled` | Meaning | | ------------------------ | ----------- | --------------- | ------------------- | ------------------------------------- | ------------------------------------------------------------------------- | | Billing normally | `active` | `null` | `false` | `true` | The subscription will be charged at the next renewal date | | Scheduled to cancel | `active` | `currentCycle` | `true` | `true` | The subscription will not renew; it cancels at `currentPeriodEnd` | | Cancelling after renewal | `active` | a later cycle | `false` | `true` | One more renewal will be charged, then the subscription cancels | | Payment retrying | `active` | either | either | `false` (`latest_invoice_retrying`) | The latest invoice payment failed and a retry is in progress | | Billing stopped | `active` | either | either | `false` (`recurring_payment_errored`) | A renewal could not be attempted; no further automatic charges will occur | | Cancelled | `cancelled` | `null` | `false` | `false` (`subscription_cancelled`) | The subscription has ended | The `autoBillingDisabledReason` field specifies why `autoBillingEnabled` was set to `false`, which is important for understanding whether the situation is temporary (a retry is pending) or permanent (billing has stopped). The `isRecovering` flag is `true` exactly when `autoBillingEnabled` is `false` with `autoBillingDisabledReason: latest_invoice_retrying`, the state a subscription is in while a failed renewal payment is retried. It reads only those two fields and says nothing about whether another attempt is scheduled. It returns to `false` when billing is restored or the subscription is cancelled. ### Why a subscription was cancelled A cancelled subscription says why in `cancellationReason`: | `cancellationReason` | Meaning | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | `requested` | You cancelled it, immediately or at a period end you booked | | `payment_failed` | A renewal payment failed and could not be recovered | | `chargeback` | One of its payments was [charged back](https://docs.odus.com/docs/guides/payments/chargebacks.md) | | `customer_blacklisted` | You added the customer's email to the [Blacklist](https://docs.odus.com/docs/guides/customers/customer-restrictions.md#the-blacklist) | It is `null` while the subscription is `active`, and on subscriptions cancelled before the field was introduced. A booked cancellation does not set it until the subscription is actually cancelled. ### Pending Cancellation A pending cancellation is recorded as `cancelAtCycle` — the cycle at whose **end** the subscription will be cancelled. `status` remains `active` throughout, and `cancelAtPeriodEnd` is simply whether that cycle is the one the subscription is in right now (`cancelAtCycle === currentCycle`). * Cancelling with `cancelAt: "end_of_current_cycle"` books the current cycle, so `cancelAtPeriodEnd` is `true` immediately. The customer retains access until `currentPeriodEnd`, and ODUS cancels after that date without charging. * Cancelling with `cancelAt: "end_of_next_cycle"` books the next one, so `cancelAtPeriodEnd` stays `false` and the upcoming renewal is charged as normal. It flips to `true` by itself once that renewal advances the cycle. Because the booking is a cycle number rather than a date, it survives the period boundary moving — for instance through the retry delays described below. Either state can be reversed — see the [Cancelling and Resuming guide](https://docs.odus.com/docs/guides/subscriptions/canceling-and-resuming.md) for details. ## The Billing Cycle ODUS evaluates subscriptions for renewal continuously. When a subscription's `currentPeriodEnd` is reached, ODUS creates a recurring invoice and attempts to charge the customer's default payment method using the billing settings recorded on the subscription. When a renewal **succeeds**, ODUS advances the subscription to the next period: * `currentPeriodStart` and `currentPeriodEnd` move forward by one billing interval * `currentCycle` increments by one * The invoice moves to `paid` once its payment is **captured** (see [Subscriptions, Invoices, and Payments](#subscriptions-invoices-and-payments) below) Period advancement depends on the payment being authorized, not on the invoice reaching `paid`. With delayed or manual capture, the subscription still advances at authorization while its invoice remains `open` until the funds are captured. The same period is never authorized twice. When a renewal **fails**, what happens next depends on the decline: * If the payment can be retried, ODUS starts a payment retry sequence. The subscription's `autoBillingEnabled` is set to `false` with reason `latest_invoice_retrying`. If a retry succeeds, `autoBillingEnabled` is restored and normal billing resumes. If the retries end without a successful payment, ODUS voids the invoice and cancels the subscription. * If the payment cannot be retried, for example after a hard decline, ODUS cancels the subscription immediately. Either cancellation sends a `subscription.cancelled` event. Billing can stop without a cancellation Sometimes a renewal cannot be attempted at all, because of an error before any charge is made. ODUS then sets `autoBillingEnabled` to `false` with reason `recurring_payment_errored`, and the subscription stays `active` without renewing. No charge was attempted, so nothing was declined. Cancel the subscription, or [reactivate it](https://docs.odus.com/docs/guides/subscriptions/canceling-and-resuming.md#reactivating-a-subscription-whose-billing-stopped) to restart billing. To find these subscriptions, filter `GET /v2/subscriptions` with `autoBillingDisabledReason[eq]=recurring_payment_errored`. ## Subscriptions, Invoices, and Payments A subscription does not charge the customer directly. Instead, ODUS creates an **invoice** for each billing event, and a **payment** settles the invoice. ``` Subscription └── Invoice (type: setup) ← created when the subscription is set up └── Invoice (type: recurring) ← created at each renewal └── Payment ← the actual charge attempt ``` There are two invoice types: * **`setup`** — created when the subscription is first activated. The `setupInvoice` field on a subscription always points to this invoice. * **`recurring`** — created at each renewal, so a subscription gains one every billing cycle. A v2 subscription object does not list its invoices. Read them from [`GET /v2/invoices`](https://docs.odus.com/docs/api/core/v2/invoice-controller-find-all-paginated) filtered by the subscription: ``` GET /v2/invoices?subscription[eq]=sub_2Q9fNQAs77Fhue9itgVFbUzq8NV ``` The list is paged, so follow `pagination.nextCursor` until `hasNextPage` is `false`. Your key needs `invoice:read`. An invoice has three possible statuses: | Invoice status | Meaning | | -------------- | ---------------------------------------------------------------------------------------------------------------- | | `open` | The invoice has been created but not yet paid or voided. An authorized-but-not-yet-captured invoice stays `open` | | `paid` | The associated payment was **captured** (the funds were collected) | | `voided` | The invoice was cancelled without being charged (for example, after a failed plan-update payment) | `paid` and `voided` are terminal: an invoice moves from `open` to exactly one of them and does not change again. A chargeback or refund after an invoice is `paid` is recorded on the **payment**, not by changing the invoice status. Chargebacks cancel the subscription If a subscription payment is charged back — whether the setup payment or a recurring renewal — ODUS cancels the subscription **immediately**. The subscription moves to `cancelled` and `autoBillingEnabled` becomes `false`. Consistent with the rule above, the chargeback does not change any invoice status: settled invoices stay `paid`, and a still-open invoice is left to its own payment lifecycle rather than being voided. See the [Chargebacks guide](https://docs.odus.com/docs/guides/payments/chargebacks.md) for how to record one. Blacklisting cancels the subscription If you add the customer's email to the Blacklist, ODUS cancels **immediately** every active subscription of every customer with that email. As with a chargeback, invoice statuses are not changed, and no new charge is attempted, because the Blacklist refuses it. See [The Blacklist](https://docs.odus.com/docs/guides/customers/customer-restrictions.md#the-blacklist). note An invoice becomes `paid` when its payment is captured, not when it is authorized. For an automatically captured payment, authorization and capture happen together, so the invoice becomes `paid` immediately. For a delayed or manual capture, the invoice stays `open` between authorization and capture, then becomes `paid` once the funds are collected. Each invoice carries a `subscription` field pointing back to the subscription that generated it. The full invoice object (from [`GET /v2/invoices/{id}`](https://docs.odus.com/docs/api/core/v2/invoice-controller-find-one)) includes a `payment` field with the associated payment details, including status and transaction history. Example A customer subscribes to a monthly plan on January 1. ODUS creates a `setup` invoice and charges it immediately. On February 1, ODUS creates a `recurring` invoice and charges the default payment method. If the February charge is captured, the invoice becomes `paid` and the subscription advances to the March period. If capture is deferred (delayed or manual capture), the subscription still advances to March at authorization while the February invoice stays `open` until the funds are captured. If the charge fails, the invoice stays `open` while retries are in progress. When [payment bundling](https://docs.odus.com/docs/guides/subscriptions/payment-bundling.md) is enabled, multiple subscriptions can share a single payment. In that case, each subscription still has its own invoice, but all invoices in the bundle are attached to one payment object. ## Related Guides * [Updating Plans](https://docs.odus.com/docs/guides/subscriptions/updating-plans.md) — how to change a subscription's pricing plan, including proration * [Payment Bundling](https://docs.odus.com/docs/guides/subscriptions/payment-bundling.md) — how multiple subscriptions can be charged in a single payment * [Cancelling and Resuming](https://docs.odus.com/docs/guides/subscriptions/canceling-and-resuming.md) — how to cancel a subscription immediately, at period end, or after one more renewal, and how to reverse a scheduled cancellation --- # Updating plans ODUS allows you to modify active subscriptions by changing their pricing plan at any time. This functionality enables both upgrades (moving to a higher-priced plan) and downgrades (moving to a lower-priced plan) with automatic proration calculations. Before applying any changes, a preview must be generated to show the customer the exact impact of the change. This ensures that both merchants and customers can understand the implications of the Subscription plan change before proceeding. ## Flow Overview ## Generating a Preview Before applying any changes to a subscription, a preview must first be generated that calculates the exact impact of the change. This preview calculation takes into account the current subscription state, the new pricing plan, and applies the configured proration strategy to determine the final result. important Always show a preview to your customers before applying subscription changes. This ensures transparency and allows customers to understand the financial impact of their plan changes. To generate a preview, send a [`POST /v2/subscriptions/{id}/preview`](https://docs.odus.com/docs/api/core/v2/subscription-controller-generate-update-preview) request with the new price ID. The request is straightforward and only requires the target price: ``` { "price": "price_new_id" } ``` The preview response includes the following: 1. `amountDue`: The total amount the customer needs to pay to proceed with the upgrade. 2. `amountCredit`: The total amount the customer will be credited after the downgrade. 3. `id`: A unique identifier for this preview, which must be used when applying the changes. 4. `previewValidUntil`: The timestamp indicating when the preview expires (15 minutes from generation). 5. `subscription`: Subscription as it will look like after the changes are applied. warning Any changes made to the subscription after generating the preview will invalidate the preview. This also includes automatic updates performed as a part of recurring billing. If the preview expires or becomes invalid, you must generate a new preview. ### Applying the New Price's Trial By default, a plan change skips the new price's trial, and the next cycle is billed at the price's regular amount and interval. To give the customer the new price's trial instead, send `applyTrial: true` with the price: ``` { "price": "price_new_id", "applyTrial": true } ``` The next cycle is then billed at the trial's amount and lasts for the trial's interval. Every cycle after it follows the price's regular amount and interval. Check `subscription.upcomingInvoice` in the preview response to see the trial amount and period before you apply the change. The request is rejected with a `400` error if it sends `applyTrial` without `price`, or if the new price has no trial. A trial you applied stays in place until its cycle is billed, including when you later change only `nextBillingDate`. Changing the price again drops it, unless that request also sends `applyTrial: true`. ### Proration Strategies ODUS uses different strategies to calculate how subscription changes are prorated. The system is designed to support multiple approaches, but currently only one strategy is available. #### Add Remaining Days Strategy The `addRemainingDays` strategy calculates proration by extending the subscription period instead of issuing credits. When both **upgrading** and **downgrading**, the customer pays **full price** of the new plan immediately, and any remaining days from the current plan are added to the new subscription period. The date of the plan update becomes the start of the new subscription period. Example (upgrade) A customer purchased a $10/month monthly plan on the 10th of the month. They used the plan for a week and decided to upgrade to an annual plan priced at $100/year on the 17th of that same month. The customer will be charged $100 for the annual plan, and the remaining days from the monthly plan (ex. 23 days) will be added to the new subscription period. This means the annual plan will start immediately after upgrading and extend for 365 days plus the remaining 23 days, resulting in a total of 388 days of service. Example (downgrade) A customer on an annual plan priced at $120/year decides to downgrade to a monthly plan priced at $10/month halfway through their annual subscription (after 6 months). The customer will not receive any credit for the remaining 6 months of the annual plan. Instead, they will be charged $10 for the monthly plan, and the remaining 6 months of the annual plan will be added to the new monthly subscription period. This means the first cycle of the new monthly subscription will last for 30 days plus the remaining 6 months, resulting in a total of 210 days of service before the next payment is due, after which the customer will be charged according to the new plan. ## Applying Subscription Changes Once you have generated and reviewed a preview, you can apply the subscription changes using the preview ID. important You must use the preview ID from the previous step. You cannot apply subscription changes without first generating a preview. This ensures that all calculations are accurate and consistent with what was shown to the customer. To apply the changes, send a [`POST /v2/subscriptions/{id}`](https://docs.odus.com/docs/api/core/v2/subscription-controller-update) request with the preview ID: ``` { "previewId": "sub_preview_generated_id" } ``` When the request is submitted, ODUS will validate the preview and subscription state to ensure they are still valid for modification. If there's an amount due from the preview, a new invoice will be created and automatically billed using the customer's default payment method through the gateway that was used to setup the subscription. warning The payment **will fail** if customer action, such as 3DS Authentication, is required. This is a safety measure as customer action is asynchronous by its nature and could lead to the preview becoming invalid before the payment has finished processing. If the invoice is not paid successfully, it is immediately voided, and the subscription remains unchanged. This means that if the payment fails, the customer will not be charged, and they can retry the upgrade after resolving any payment issues. ## Edge Cases To ensure the smoothest user experience, there are some edge cases to consider. ### Invalidated Previews As mentioned earlier, previews can be invalidated due to: * Changes made to the subscription after the preview was generated, including automatic system updates. * The preview expiration time of 15 minutes being reached. If a preview becomes invalid, you must generate a new preview before applying any changes. This ensures that the customer sees the most accurate and up-to-date financial impact of their subscription changes. ### Declined Payments In case of a declined payment, the invoice will be voided, and the subscription will not be updated. This will be accompanied by a 400 error response indicating that the invoice billing failed. We recommend that the customer is informed about the payment failure. important At this moment, the system does not support subscription upgrades or downgrades with a payment method that is not the customer's default payment method. --- # Testing resources This guide covers everything you need to test your ODUS integration in sandbox before going live: test card numbers, example webhook payloads. ## Test card numbers Use the card numbers below to simulate different payment outcomes. For all cards: * Use **any future expiry date** (e.g. `12/40`) * Use **any CVV** (e.g. `100`) * Use **any cardholder name** (e.g. `Test User`) ### Stripe | Card number | Brand | Scenario | Description | | ------------------ | ---------- | -------- | ---------------------------------------------------- | | `4242424242424242` | Visa | Success | Payment is authorized immediately | | `5555555555554444` | Mastercard | Success | Payment is authorized immediately | | `378282246310005` | Amex | Success | Payment is authorized immediately | | `6011111111111117` | Discover | Success | Payment is authorized immediately | | `4000000000003220` | Visa | 3DS | Triggers a 3DS redirect; complete to authorize | | `4000000000000002` | Visa | Declined | Card is always declined with a generic decline error | For the full list, see the [Stripe testing documentation](https://docs.stripe.com/testing#cards). ### Adyen | Card number | Brand | Scenario | Description | | ------------------ | ---------- | -------- | ---------------------------------------------- | | `370000000000018` | Amex | Success | Payment is authorized immediately | | `5555444433331111` | Mastercard | Success | Payment is authorized immediately | | `6011000000000004` | Discover | Success | Payment is authorized immediately | | `371449635398431` | Amex | 3DS | Triggers a 3DS redirect; complete to authorize | For the full list, see the [Adyen test cards documentation](https://docs.adyen.com/development-resources/testing/test-card-numbers/). ### Checkout.com | Card number | Brand | Scenario | Description | | ------------------ | ----- | -------- | --------------------------------- | | `4242424242424242` | Visa | Success | Payment is authorized immediately | For the full list, see the [Checkout.com testing documentation](https://www.checkout.com/docs/testing). ### PayPal PayPal does not use test card numbers. Instead, you create sandbox buyer and seller accounts in the [PayPal Developer Dashboard](https://developer.paypal.com/). See the [PayPal integration guide](https://docs.odus.com/docs/guides/integrations/gateways/paypal/.md) for setup instructions. ## Test webhook payloads After [creating a webhook subscriber](https://docs.odus.com/docs/guides/dashboard/webhook-subscriber.md), you will receive events at your configured URL. Below are example payloads for common events. The `data` field structure matches the corresponding API response — see the [Webhooks guide](https://docs.odus.com/docs/guides/api-features/webhooks.md) for full details. tip Use [ngrok](https://ngrok.com/) or a similar tool to expose your local server to the internet during development. You can also inspect delivered webhooks in the Dashboard under [Request Logs](https://docs.odus.com/docs/guides/dashboard/request-logs.md). ### payment.created Sent when a payment is created via `POST /v2/payments`. ``` { "eventId": "evt_abc123", "eventType": "payment.created", "profile": "whs_xyz456", "timestamp": "2026-05-12T10:00:00Z", "data": { "id": "pay_def789", "status": "open", "displayStatus": "unattempted", "amount": 1000, "currency": "usd", "amountCaptured": 0, "amountReversed": 0, "isCaptured": false, "isReversed": false, "isFullyReversed": false, "isChargebacked": false, "isRetrying": false, "isRecovered": false, "initiator": "customer", "customer": null, "paymentMethod": null, "metadata": {} } } ``` ### payment.succeeded Sent when a payment is successfully authorized (and captured, if automatic). ``` { "eventId": "evt_ghi012", "eventType": "payment.succeeded", "profile": "whs_xyz456", "timestamp": "2026-05-12T10:01:30Z", "data": { "id": "pay_def789", "status": "succeeded", "displayStatus": "succeeded", "amount": 1000, "currency": "usd", "amountCaptured": 1000, "amountReversed": 0, "isCaptured": true, "isReversed": false, "isFullyReversed": false, "isChargebacked": false, "isRetrying": false, "isRecovered": false, "initiator": "customer", "customer": { "id": "cus_jkl345" }, "paymentMethod": { "id": "pm_mno678", "type": "card", "card": { "brand": "visa", "lastFour": "4242", "expMonth": "12", "expYear": "2040" } }, "metadata": {} } } ``` ### payment.attempt\_failed Sent when a payment authorization attempt fails (e.g. card declined). ``` { "eventId": "evt_pqr901", "eventType": "payment.attempt_failed", "profile": "whs_xyz456", "timestamp": "2026-05-12T10:01:30Z", "data": { "id": "pay_def789", "status": "open", "displayStatus": "failed", "amount": 1000, "currency": "usd", "amountCaptured": 0, "amountReversed": 0, "isCaptured": false, "isReversed": false, "isFullyReversed": false, "isChargebacked": false, "isRetrying": false, "isRecovered": false, "initiator": "customer", "customer": { "id": "cus_jkl345" }, "paymentMethod": null, "metadata": {} } } ``` ### payment.requires\_action Sent when a payment requires customer action, such as 3DS authentication. ``` { "eventId": "evt_stu234", "eventType": "payment.requires_action", "profile": "whs_xyz456", "timestamp": "2026-05-12T10:01:15Z", "data": { "id": "pay_def789", "status": "requires_action", "displayStatus": "incomplete", "amount": 1000, "currency": "usd", "amountCaptured": 0, "amountReversed": 0, "isCaptured": false, "isReversed": false, "isFullyReversed": false, "isChargebacked": false, "isRetrying": false, "isRecovered": false, "initiator": "customer", "customer": { "id": "cus_jkl345" }, "paymentMethod": null, "action": { "redirectUrl": "https://sandbox-api.odus.com/checkout/redirect/pay_def789" }, "metadata": {} } } ``` ### subscription.active Sent when a subscription becomes active after a successful initial payment. ``` { "eventId": "evt_vwx567", "eventType": "subscription.active", "profile": "whs_xyz456", "timestamp": "2026-05-12T10:02:00Z", "data": { "id": "sub_yza890", "status": "active", "customer": { "id": "cus_jkl345" }, "items": [ { "price": { "id": "price_bcd123", "amount": 1000, "currency": "usd", "interval": "month", "intervalCount": 1 }, "quantity": 1 } ], "currentPeriodStart": "2026-05-12T10:02:00Z", "currentPeriodEnd": "2026-06-12T10:02:00Z", "metadata": {} } } ``` --- # Upgrading Core API v2 is the current version of the API. **v1 stops being served at 00:00 UTC on 4 November 2026.** If your integration calls `/v1` routes or unprefixed routes such as `GET /payments`, or uses a Checkout SDK version earlier than `1.12.0`, upgrade before then. * [Upgrading from v1 to v2](https://docs.odus.com/docs/guides/upgrading/v1-to-v2.md) — what v2 gives you, and the steps to move your integration. Required before 4 November 2026. * [Upgrading webhooks to payload version 2](https://docs.odus.com/docs/guides/upgrading/webhook-payload-v2.md) — webhook bodies that show each resource at the moment of the event. Optional, with no deadline. --- # Upgrading from v1 to v2 Core API v2 is the current version of the API. **v1 stops being served at 00:00 UTC on 4 November 2026**, so 3 November is the last day it answers. Upgrade before then. You can move one flow at a time, and nothing moves to v2 for you. Two things stop working on that date: * **Requests to v1.** That includes `/v1` routes and unprefixed routes such as `GET /payments`. Every v1 response carries `Deprecation`, `Sunset` and `Link` headers, so logging them is the quickest way to find the code you still have to move. From the sunset, every v1 request answers `410 Gone` with `code: "api_version_sunset"`, whether or not its API key is valid. Retrying does not help: send the request under `/v2`. * **Checkout SDK versions earlier than `1.12.0`.** Version `1.12.0` is the first release that calls only API v2. Upgrade with `npm install @odus/checkout@latest`. Webhook payload versions are separate from API versions, and this date does not apply to them. A subscriber on payload version 1 keeps receiving it after 4 November 2026. ## What v2 gives you ### For building and debugging your integration * **Webhook types you can generate.** The v2 OpenAPI document describes every webhook event and its body, so you can generate types for your webhook handlers as well as for your requests. See the [Webhooks section of the API reference](https://docs.odus.com/docs/api/core/v2/webhook-customer-created). * **A document current generators read.** The v2 OpenAPI document is OpenAPI 3.1.1 with JSON Schema 2020-12 schemas. The v1 document is OpenAPI 3.0.0. See [If you generate code from the OpenAPI document](#if-you-generate-code-from-the-openapi-document). * **Webhook delivery history.** [`GET /v2/webhook-deliveries`](https://docs.odus.com/docs/api/core/v2/webhook-controller-find-all-deliveries-paginated) lists every delivery attempt. Filter it by subscriber, event type or `success` to see what failed and whether it is still being retried. * **An event timeline for any object.** [`GET /v2/events`](https://docs.odus.com/docs/api/core/v2/event-controller-find-all-paginated) returns everything that happened to the payments, subscriptions or other objects you name, in order, whether or not a webhook was sent for it. * **Routes you can predict.** Every request is a `GET` or a `POST`, and the same naming rules apply across the whole API. See [How v2 names routes](#how-v2-names-routes). * **Published rate-limit terms.** A `429` tells you when to retry, and we announce a reduction in a limit at least 30 days ahead. See [Rate limits](https://docs.odus.com/docs/guides/api-features/errors.md#rate-limits). ### For payments, subscriptions and lists * **Declines have their own status.** A payment that did not go through answers `402`, so you can tell it apart from a malformed request, which answers `400`. * **More precise decline codes.** Eight new values of `outcome.errorCode` and a new decline type, `not_attempted`, separate problems with your account or request from problems with the customer's payment method. Each comes with a `retryStrategy`. See [Codes added in v2](https://docs.odus.com/docs/guides/payments/error-codes.md#codes-added-in-v2). * **Restart billing on a subscription.** [`POST /v2/subscriptions/{id}/reactivate`](https://docs.odus.com/docs/api/core/v2/subscription-controller-reactivate) restarts a subscription whose billing stopped. See [Reactivating a subscription whose billing stopped](https://docs.odus.com/docs/guides/subscriptions/canceling-and-resuming.md#reactivating-a-subscription-whose-billing-stopped). * **Cursor pagination on every list.** Pages stay consistent while data changes, and a page holds up to 100 rows. Send `withTotal=true` when you need a count. See [Pagination, filtering, sorting](https://docs.odus.com/docs/guides/api-features/pagination-filtering-sorting.md). * **Case-insensitive search when you ask for it.** The `ilike` filter matches regardless of case, and supports wildcards for partial matches. You can also move your webhook subscribers to [payload version 2](https://docs.odus.com/docs/guides/upgrading/webhook-payload-v2.md), which sends each resource as it was at the moment of the event. It is optional and has no deadline. ## Before you start Select v2 by adding the `/v2` prefix to Core API URLs: ``` GET /v2/payments POST /v2/subscriptions/{id}/cancel ``` The prefix is the only way to choose a version. There is no version header and no `Accept` parameter. The prefix applies to the whole request, so you cannot mix versions within one call. Unprefixed routes and `/v1` routes keep v1 behaviour until the sunset date. Any other version-shaped prefix, such as `/v3/payments` or `/v2beta/payments`, answers `404`, and nothing falls back to v1. A typo in the prefix fails loudly instead of quietly serving you the old contract. Much of the API is unchanged. A renamed route keeps the request body, response body and permissions it has in v1; only the path moves. Logical `$and` and `$or` filters work as they do in v1. How to read this page Most of what changed tells you so: a route that moved answers `404` under `/v2`, and a parameter v2 no longer accepts answers `400`. A test run against `/v2` finds those for you. The rest do not. They answer `200` while returning different rows, or accept your payload and act on it differently. Each one is marked **Silent change**, and reading your own code is the only way to find them. The one marked in red can keep billing a subscription you meant to cancel. ## Upgrade in this order 1. **Upgrade the Checkout SDK if you use it.** Install `@odus/checkout` `1.12.0` or later before 4 November 2026. Version `1.12.0` is the first Checkout SDK release that calls only API v2. Any SDK route that still uses API v1 stops working when v1 stops being served. 2. **Point a non-production copy of your integration at `/v2`,** one flow at a time. 3. **Look up every route you call** in the [route map](#route-map). Repoint the ones that moved, and find replacements for the ones marked **Retired** — those have no v2 equivalent and stop working when v1 does. A route missing from that table kept its path — it has not necessarily kept its behaviour, which is what the rest of this page covers. 4. **Move list calls to cursor pagination.** Replace `page` and `pageSize` with `limit`, and page by sending the `pagination.nextCursor` you got back as the next request's `cursor`. Check what reads `itemCount` or `pageCount` — v2 returns neither, but `withTotal=true` gives you a capped `totalCount` in their place. If you ask for a page size above 100, expect 100 rows back where v1 gave you 10. See [Lists page by cursor only](#lists-page-by-cursor-only). 5. **Change every text filter whose value's case you do not control.** Send `ilike` instead of `eq`, and replace any `like` you were using as a substring match. Email filters are the exception and need no change. On an ID or a fixed value such as `status` or `currency`, keep `eq` or `in` and send the value in the case the API returns it: those fields refuse `like` and `ilike` with `400`. See [String filters are case-sensitive](#string-filters-are-case-sensitive). 6. **Handle `402` on `POST /v2/payments/{id}/authorize`,** or branch on `type` instead of the status. See [Payments](#payments). 7. **Check what reads `outcome.errorCode` or `outcome.declineType`.** v2 returns eight decline codes v1 never sends, and a third decline type. Three of the eight also change retry strategy relative to the v1 code they replace; the other five were already `never_retry`. See [Payments](#payments). 8. **Stop sending the request fields v2 dropped.** Search your payloads for each one: | Field | Sent to | Send instead | | ------------------- | ----------------------------------- | ---------------------------------------------------- | | `cancelImmediately` | `POST /subscriptions/{id}/cancel` | [`cancelAt`](#subscriptions) | | `billingSchedule` | `POST /prices`, `POST /prices/{id}` | [`recurringSchedule`](#prices) | | `active` | `POST /secret-keys/{id}` | Nothing. [Delete the key](#secret-keys) to revoke it | note `cancelImmediately` and `billingSchedule` are still accepted silently on v2, so a test run against `/v2` will not find them for you. `active` is refused with `400`. 9. **Stop reading the nested collections v2 dropped.** `customer.paymentMethods`, `customer.subscriptions` and `subscription.invoices` are no longer in the response; read each from its own list endpoint. See [Growing collections moved out of the resource](#growing-collections-moved-out-of-the-resource). 10. **Read the section for each resource you use** in [Changes by resource](#changes-by-resource), then check your remaining routes against the [v2 API reference](https://docs.odus.com/docs/api/core/v2/odus-core-api). ## Where your v1 routes went ### Route map Every v1 route whose **path** moves or disappears in v2. Called under `/v2`, a route that moved answers `404`, so a test run against `/v2` finds them all; their v1 paths keep working until the sunset. A route that is not listed keeps its path, but may still behave differently — see [Changes to every list endpoint](#changes-to-every-list-endpoint) and [Changes by resource](#changes-by-resource). Rows marked **Retired** are the ones to plan around: they have no v2 replacement and none is coming, so they go away with v1 on 4 November 2026 rather than waiting for you. | v1 operation | In v2 | What to do | | --------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `POST /payments/{id}/record-chargeback` | `POST /v2/payments/{id}/chargebacks` | Repoint. A chargeback is an object you create, not a change to the payment's state. | | `POST /payments/{id}/create-apple-pay-session` | `POST /v2/payments/{id}/apple-pay-sessions` | Repoint. The session is an object you create. | | `POST /checkout/init` | — | Create the payment first, then read `GET /v2/payments/{id}/checkout-details`. | | `GET /payments/{id}/supported-payment-methods` | — | Use `GET /v2/payments/{id}/checkout-details` for Checkout payment-method availability. | | `POST /subscriptions/bulk-update` | — | **Retired.** No replacement. Update each subscription with `POST /v2/subscriptions/{id}`. | | `POST /subscriptions/bulk-update/preview` | — | **Retired.** No replacement. Preview one at a time with `POST /v2/subscriptions/{id}/preview`. | | `POST /customers` | — | **Retired.** No replacement. Customers stay readable in v2 (`GET /v2/customers`, `GET /v2/customers/{id}`); new ones are created by the Checkout flows rather than by an API call. | | `DELETE /blacklist/{id}` | `POST /v2/blacklist/{id}/delete` | Repoint. Removal has preconditions and emits `blacklist.removed`, so it is a command like every other change. | | `GET /blacklist/check` | — | Filter the list instead — see [Blacklist](#blacklist). | | `GET /webhooks` | `GET /v2/webhook-deliveries` | Repoint. The rows are delivery attempts; what you configure is a webhook *subscriber*, at `/v2/webhook-subscribers`. | | `GET /merchant-configs` | `GET /v2/merchant-config` | Repoint. You have exactly one merchant configuration. | | `POST /merchant-configs/{id}` | `POST /v2/merchant-config` | Repoint, and stop fetching the id first — you no longer need it to update. | | `POST /retry-policies`, `GET /retry-policies/default`, `GET /retry-policies/{id}`, `POST /retry-policies/{id}` | `POST /v2/retry-policy`, `GET /v2/retry-policy` | See [Retry policy](#retry-policy). | | `GET /jobs`, `GET /jobs/{id}`, `GET /jobs/{id}/items` | — | **Retired.** No replacement. | | `GET /publishable-keys/active`, `GET /publishable-keys/{id}`, `POST /publishable-keys/{id}`, `POST /publishable-keys/roll` | — | **Retired.** No replacement. | | `GET /users`, `POST /users`, `GET /users/{id}`, `POST /users/{id}`, `DELETE /users/{id}`, `GET /users/me`, `POST /users/me` | — | **Retired.** No replacement. Users are part of Dashboard identity, not the Core API. | | `POST /login`, `POST /logout`, `POST /password-reset/request`, `POST /password-reset/confirm` | — | **Retired.** No replacement. Signing in is part of Dashboard identity, not the Core API. | | `POST /newsletter/subscribe` | — | **Retired.** No replacement. | | `POST /token-exchange` | — | **Retired.** No replacement. Use payment authorization and the Checkout SDK flows instead. | ### How v2 names routes These rules cover the whole v2 surface, so once you have repointed a route you can usually predict the next one rather than look it up: * **Every request is a `GET` or a `POST`.** There is no `PATCH`, `PUT` or `DELETE` anywhere in v2, and none will be added. A `GET` is always safe; anything that changes something is a `POST`. The reverse does not hold, so branch on the route, not the method: `POST /v2/subscriptions/{id}/preview` is a read served over `POST` because its input does not fit in a URL. It changes nothing, it is safe to repeat, an `X-Idempotency-Key` sent with it is ignored rather than replayed, and it is the only such route on the v2 surface. * **Deleting is a command:** `POST /v2/{resource}/{id}/delete`. * **Moving a resource through its lifecycle is a verb:** `POST /v2/payments/{id}/capture`, `POST /v2/subscriptions/{id}/cancel`. * **Producing a new object is a create on a sub-resource:** `POST /v2/payments/{id}/chargebacks`, not `record-chargeback`. * **Changing fields on a resource needs no action at all:** `POST /v2/{resource}/{id}`. * **A resource you have exactly one of is a singleton** — singular, and with no id: `GET /v2/merchant-config`, `POST /v2/retry-policy`. * **"The one that is mine" is always `self`:** `/v2/accounts/self`. ## Changes to every list endpoint This section covers `GET /v2/payments`, `GET /v2/customers` and every other list route. If your integration calls none of them, skip to [Changes by resource](#changes-by-resource). ### Lists page by cursor only `page` and `pageSize` are not part of the v2 contract. A v2 request carrying either is answered with `400`, and `errors` names the parameter — they are not ignored, because `?page=5` would otherwise return the first page's rows while looking like the fifth. `limit` is no longer the switch that opts into cursor pagination; in v2 it is the page size, defaulting to 10 and capped at 100. A request with no pagination parameters returns the first cursor page: ``` GET /v2/payments GET /v2/payments?limit=25 ``` Follow `pagination.nextCursor` to walk the result set, and send it back in `cursor` with the same `sortBy` and `sortDirection` — the cursor encodes those, and a request that disagrees is rejected. Stop when `pagination.hasNextPage` is `false`. What you give up is jump-to-page: v2 responses carry no `itemCount` and no `pageCount`, and there is no way to ask for "page 5". If you were reading `itemCount`, send `withTotal=true` and read `totalCount` instead — it counts what your filters match, up to 10,000, and tells you with `totalCountIsExact` when the real figure is higher. See [Counting the matches](https://docs.odus.com/docs/guides/api-features/pagination-filtering-sorting.md#counting-the-matches). See [Pagination, filtering, sorting in v2](https://docs.odus.com/docs/guides/api-features/pagination-filtering-sorting.md) for the full flow. #### A page size over the cap is corrected, not refused Silent change Neither version refuses an out-of-range page size, and the parameter you migrate *from* corrects it differently from the one you migrate *to*. | Page size you ask for | v1, via `pageSize` | v2, via `limit` | | --------------------- | --------------------- | --------------------------------------------------------- | | `500` | 10 rows — the default | 100 rows — the cap | | `0` | 10 rows | **no rows** — v2 reads it as an explicit request for none | | A non-number | 10 rows | 10 rows | | `50` | 50 rows | 50 rows | Both answer `200`, and nothing in the response says the value was changed. So an oversized number that quietly gave you 10 rows a page in v1 quietly gives you 100 in v2. This matters if you treat the page size as a promise — sizing a buffer from it, or assuming one oversized request drained the result set. Read the length of the `data` array you were actually given, and keep paging until `pagination.hasNextPage` is `false`. (`nextCursor` is always present; it is `null` on the last page rather than absent, so a client waiting for the field to disappear waits forever.) v1's `limit` clamps the same way v2's does; it is `pageSize`, which has no v2 equivalent, that behaves differently. ### String filters are case-sensitive Silent change In v1, ordinary string filters lowercased both the stored value and yours, so a filter could match despite a case mismatch. In v2 they compare the stored value as it is, and a query that no longer matches returns an empty page rather than an error. Nothing in the response says the filter was the reason. ``` GET /v1/payments?status[eq]=Succeeded # matches a stored "succeeded" GET /v2/payments?status[eq]=Succeeded # empty page ``` Send a fixed value such as a `status` in the case the API returns it: v2 accepts only `eq`, `ne`, `in` and `nin` on those fields (see [Operators v2 changed](#operators-v2-changed)). On a text field, send `ilike` wherever you need case-insensitive matching: ``` GET /v2/products?name[ilike]=Gift Card ``` `like` changed too. In v1 it matched a substring, wrapping your value as `%value%`; in v2 it matches a prefix, `value%`. So a v1 substring search returns fewer rows in v2 without failing. `ilike` compares the value exactly as you send it, wildcards included, which is how you get a substring match back. `%` is the wildcard, percent-encoded as `%25` in a URL: ``` GET /v2/customers?name[ilike]=%25doe%25 ``` That matches any name containing "doe", in any case. `%` and `_` are wildcards in the value you send, so if the value comes from a search box or any input you do not control, escape or strip them first — a shopper typing `%` otherwise matches every row. `contains` is not a text search: it applies only to a field that holds several values, such as `supportedPaymentMethods`, and the API reference lists it on those fields. On an ordinary string field such as `name`, a request that sends it is answered with `400`. Use `like` or `ilike`. #### Filter customer names with `ilike`, not `eq` For an enum-like field such as `status`, exact case is what you want: the values are a fixed, known set. For `name` on `/v2/customers` it is not, because the stored value is whatever a merchant or shopper typed, so looking one up by a differently-cased spelling is a normal thing to do: ``` GET /v2/customers?name[ilike]=John Doe ``` That is an exact match, ignoring case; wrap the value in `%25` as above if you want partial matches. `ilike` on `name` or `phoneNumber` carries no performance penalty. `email` needs no change, and should stay on `eq`: ODUS stores it lowercased and lowercases the value you filter by, so `eq` matches whichever case you send — and it is the faster path for an address. The same holds for `email` on `/v2/blacklist` and `customer.email` on `/v2/subscriptions`. See [Case sensitivity](https://docs.odus.com/docs/guides/api-features/pagination-filtering-sorting.md#case-sensitivity) for the details. Casing in a name is meaningful and is preserved as typed, which is why `name` is not treated the same way. #### Operators v2 changed Compared with v1, the v2 schema: * adds `ilike` to text filters, for explicit case-insensitive matching; * keeps `eq`, `ne`, `like`, `in` and `nin` on text filters; * accepts only `eq`, `ne`, `in` and `nin` on fields matched against their whole value: IDs, references to other objects, fields that hold one of a fixed set of values such as `status`, `currency` or `type`, and card digits such as `card.lastFour`. `like` or `ilike` on one of them is answered with `400`, naming the field. If you match one of these by part of its value, send the whole value with `eq`, or list the values you want with `in`. The [endpoint tables](https://docs.odus.com/docs/guides/api-features/pagination-filtering-sorting.md#supported-v2-endpoints) mark each one as String (exact match); * accepts only `eq` and `in` on the two processor ID filters, `processorId` on `GET /v2/transactions` and `latestTransaction.processorId` on `GET /v2/payments`, and on `url` on `GET /v2/logs`. Any other operator on them is answered with `400`. If you search by part of a processor ID, send the whole ID with `eq` instead, in the case the processor returned it. See [Filtering](https://docs.odus.com/docs/guides/api-features/pagination-filtering-sorting.md#filtering); * keeps `eq`, `ne`, `like`, `in` and `nin` on JSON metadata values; * removes one filter field from a list endpoint both versions serve: `active` on `GET /v2/secret-keys`, which v2 ignores rather than refuses. See [Secret keys](#secret-keys). `contains` changed in both versions at once, so it is not a v2 migration step. It now applies only to a field that holds several values, and both versions answer `400` when you send it on any other field. If your query builder sends `contains` on a string, number, boolean or date field, remove it: express the condition with `eq`, `ne`, `in`, `nin`, a `gte`/`lte` range, or — for text — `like` or `ilike`. info A request that previously sent `contains` on one of those fields never returned the rows you asked for. It now names the field in a `400` instead. List endpoints that v2 does not serve take their filterable and sortable fields with them: Users, Jobs and Job Items have no v2 list endpoint at all. See [Pagination, filtering, sorting in v2](https://docs.odus.com/docs/guides/api-features/pagination-filtering-sorting.md) for the exact endpoint and field list. ## Growing collections moved out of the resource Silent change Three fields are gone from v2 responses. Nothing errors — the field is simply absent, so code that reads it gets `undefined`, and code that loops over it fails where you wrote the loop rather than where the response arrived. | Gone from | Field | Read instead | | ---------------------------- | ---------------- | ------------------------------------------- | | `GET /v2/customers/{id}` | `paymentMethods` | `GET /v2/payment-methods?customer[eq]={id}` | | `GET /v2/customers/{id}` | `subscriptions` | `GET /v2/subscriptions?customer[eq]={id}` | | `GET /v2/subscriptions/{id}` | `invoices` | `GET /v2/invoices?subscription[eq]={id}` | Each of those grows for as long as the customer keeps buying or the subscription keeps billing, and a field nested in one resource has no page to stop at. Its own list endpoint does: you get the page you asked for, sorted the way you asked, and the same rows — the filters above select exactly what the nested field used to carry. Only those three moved. The nested objects around them are unchanged, so a v2 subscription still carries its `customer`, its `price` and its `upcomingInvoice`, and a v2 customer still carries `billingAddress` and `shippingAddress`. Every route answering with a customer or a subscription is affected, not only the `GET`. `POST /v2/customers/{id}`, `POST /v2/customers/{id}/restrictions`, `POST /v2/subscriptions/{id}`, `POST /v2/subscriptions/{id}/cancel` and `POST /v2/subscriptions/{id}/resume` all return the narrower shape. Three things to check as you repoint: * **The rows now arrive a page at a time.** The nested field returned everything at once; a list endpoint returns 10 rows by default and up to 100. Keep paging until `pagination.hasNextPage` is `false` — see [Lists page by cursor only](#lists-page-by-cursor-only). * **Your key needs the permission for what you are reading.** `customer:read` was enough to receive the nested payment methods and subscriptions; reading them now needs `payment_method:read` and `subscription:read`, and the invoices need `invoice:read`. A key without them is answered `403` with `type` `permission_error`. * **You can narrow further than the nested field allowed.** `GET /v2/subscriptions?customer[eq]={id}&status[eq]=active` returns only the active ones, and `GET /v2/payment-methods?customer[eq]={id}&isDefault[eq]=true` returns the customer's default payment method — whatever its type — without you filtering the array yourself. All three fields are unchanged in v1, so an integration that needs the nested shape can stay on v1 for those calls while moving the rest. ## Changes by resource A resource that is not listed here changes only through its rows in the [route map](#route-map) and the [list-endpoint changes](#changes-to-every-list-endpoint) above. ### Payments **A declined payment answers `402`, not `400`.** `POST /v2/payments/{id}/authorize` answers a payment the API processed but could not complete with `402 Payment Required`; v1 answers the same failure with `400`. Usually that is a refusal from the issuer, the processor or risk — but not always, since a payment that never reached an authorization answers `402` too. `type` is `payment_error` in both versions, so an integration that branches on `type` — which is what [Errors](https://docs.odus.com/docs/guides/api-features/errors.md) recommends — needs no change. The body is not always identical: for the eight codes above, v1 derives its message from the broader code it collapses to, so the wording differs between versions. If you branch on the status, treat `402` as "the payment did not go through" and keep `400` for "the request was malformed". Whether to try again is not encoded in the status — plenty of `402`s are soft declines that succeed on a later attempt. Read `outcome.retryStrategy` on the transaction to decide, and `outcome.errorCode` to find out why it failed, rather than the error body. **Eight decline codes are new, and a third decline type comes with them.** Silent change — decline handling `outcome.errorCode` on the transaction can carry eight values on v2 that v1 never returns: `already_captured`, `merchant_account_restricted`, `merchant_configuration_error`, `invalid_request`, `blocked_by_policy`, `order_not_permitted`, `payment_method_unusable` and `stored_credential_revoked`. Each one is a case that v1 reports as a broader code — `already_captured`, for instance, arrives as `generic_hard_decline` on v1. `outcome.declineType` gains a third value alongside them, `not_attempted`, which v1 never returns either. It marks a payment that was never authorized against the payment method, so nothing was refused: all eight codes above carry it. **If you branch on `hard_decline` to decide a stored payment method is unusable, add explicit `payment_method_unusable` and `stored_credential_revoked` branches.** That branch stops firing for all eight on v2 — correctly for six of them, none of which is about the customer's payment method: they concern your account, your request, the order, a policy rule, or a capture that already succeeded. The other two *are* about the stored method, and they are the cases where you still have to ask the customer for a new one. Without those branches your integration stops asking. Nothing errors. If you branch on `errorCode` or `declineType`, the new values fall through to whatever your default branch does, and `retryStrategy` moves with them: all eight are `never_retry`, where three of the codes they replace are retryable on v1. Handle them, or key your retry logic on `retryStrategy` rather than on the code. [Error Codes](https://docs.odus.com/docs/guides/payments/error-codes.md#codes-added-in-v2) lists each one against the v1 code it replaces. ### Subscriptions **`invoices` is no longer in the response.** Read the subscription's billing history from `GET /v2/invoices?subscription[eq]={id}` — see [Growing collections moved out of the resource](#growing-collections-moved-out-of-the-resource). **`cancelImmediately: true` must become `cancelAt: "immediately"`.** Silent change — billing timing In v1 you cancelled a subscription immediately with `{ "cancelImmediately": true }`. In v2, send `{ "cancelAt": "immediately" }` instead. If you drop `cancelImmediately` without adding `cancelAt`, ODUS applies the default, `end_of_current_cycle`. The subscription then stays active — and keeps billing — until the end of the current cycle, and the request succeeds without an error. `cancelAt` accepts `immediately`, `end_of_current_cycle` and `end_of_next_cycle`. If you were sending `cancelImmediately: false`, drop the field: `end_of_current_cycle` is already the default. Send one field or the other, not both. `cancelAt` wins wherever both are present, except that `cancelImmediately: true` combined with any `cancelAt` other than `immediately` is refused with `400`, and `errors` names `cancelAt`. ### Customers **`paymentMethods` and `subscriptions` are no longer in the response.** Read each from its own list endpoint — see [Growing collections moved out of the resource](#growing-collections-moved-out-of-the-resource). `POST /customers` is retired and has no v2 route: new customers are created by the Checkout flows rather than by an API call. `GET /v2/customers` and `GET /v2/customers/{id}` are otherwise unchanged. If you look customers up by name, [filter with `ilike`](#filter-customer-names-with-ilike-not-eq) — this is the change most likely to affect a support tool. ### Prices `billingSchedule` is no longer a field you can *send*. v2 recurring prices are created with `recurringSchedule`, which v1 accepts too — so you can switch to it before you move to v2 and confirm the prices you create are the ones you expect. Replace the field rather than adding to it: a request carrying both is refused. You can still read it: `GET /v2/prices/{id}` returns `billingSchedule` on prices that have one, but only when the request is authenticated with a secret key. The list route, `GET /v2/prices`, does not return it at all. It is not a renamed `billingSchedule`: the two describe the schedule differently, so renaming the key is not enough. `recurringSchedule` requires `intervalUnit` and `intervalCount`, which a `billingSchedule` value does not carry, and the request is refused as invalid. Transform the value as well as the key. ``` { "name": "Pro Plan — Monthly", "type": "recurring", "currency": "usd", "amount": 2999, "product": "prod_2QGNGy637hJ9MnD5cK7thAjSvPV", "recurringSchedule": { "intervalUnit": "month", "intervalCount": 1 } } ``` `intervalUnit` and `intervalCount` are required, `trial` is optional, and the amount charged each cycle is the price's own `amount` rather than one carried per cycle. `billingSchedule.statementDescriptor` has no `recurringSchedule` equivalent: the descriptor that reaches the bank statement is the product's `statementDescriptor`, so set it there. Multi-cycle prices are retired A `billingSchedule` with several `cycleDefinitions` — a different amount or interval per position — has no `recurringSchedule` equivalent, and none is coming. v2 does not accept `billingSchedule` on `POST /v2/prices` or `POST /v2/prices/{id}`, and v1 stops serving on 4 November 2026, so after that date there is no route that creates one. If your integration creates multi-cycle prices, raise it with us before that date rather than planning a migration around it. ### Blacklist **Removing an entry is a command:** `POST /v2/blacklist/{id}/delete` replaces `DELETE /blacklist/{id}`. **Checking an email is a filter.** `GET /blacklist/check` is not served by v2. Filter the list instead: ``` GET /v2/blacklist?email[eq]=shopper@example.com ``` An empty `data` array means the email is not blacklisted. Blacklisted emails are stored and compared in lowercase, so the case you send does not matter. ### Retry policy `POST /v2/retry-policy` replaces four v1 routes: `POST /retry-policies`, `GET /retry-policies/default`, `GET /retry-policies/{id}` and `POST /retry-policies/{id}`. * It **creates** the policy if you have none and **replaces** it if you do, so send the whole policy — `title` and the full `steps` list — every time. * `GET /v2/retry-policy` answers **`404`** until a policy exists, where v1 answered `200` with a null body. Treat that `404` as "no retry policy configured" rather than as a failure, and `POST /v2/retry-policy` to create one. * `isDefault` is no longer in the v2 response. You have one retry policy, so the flag never carried information: treat it as absent and do not branch on it. You can drop the "only one policy" error handling In v1, a second `POST /retry-policies` fails with `Only one Retry Policy is allowed`. `POST /v2/retry-policy` succeeds whether or not a policy exists, so any code handling that error can go. ### Secret keys **You revoke a key by deleting it.** v2 has no `active` flag on secret keys: * It is not in any v2 secret key response, including the one `POST /v2/secret-keys` returns. * `POST /v2/secret-keys/{id}` answers `400` when the body sends `active`. Send only `note` and `permissions`. * To stop a key working, delete it with `POST /v2/secret-keys/{id}/delete`. If something still needs a key, create its replacement with `POST /v2/secret-keys` and switch over first. Silent change `GET /v2/secret-keys` ignores an `active` filter rather than refusing it, so `active[eq]=true` returns every key. Remove the filter, and delete any key you no longer use. ## If you generate code from the OpenAPI document The v2 document is OpenAPI 3.1.1 with JSON Schema 2020-12 schemas. The v1 document remains OpenAPI 3.0.0, so a generator pinned to 3.0 needs upgrading before it can read v2. One difference is easy to miss: **`initiator` on request-log entries is an open string in v2.** v1 constrains it to a fixed set of credential types; v2 publishes it as a nullable string with no enumeration. Do not generate an exhaustive `switch` over it. The other is that **the `POST` that is a read no longer documents `409`.** v2 lists `409 Conflict` only on operations that change something, so a client generated from the v2 document has no conflict branch for `POST /v2/subscriptions/{id}/preview`. --- # Upgrading webhooks to payload version 2 Payload version 2 changes the shape of `data` in the webhooks you receive. It is optional and separate from the API version. It has no deadline: a subscriber on payload version 1 keeps receiving it after Core API v1 stops being served on 4 November 2026. ## What version 2 gives you * **`data` is the resource at the moment of the event.** On version 1, `data` is read when the webhook is built, so it can include changes made after the event. Version 2 takes its snapshot together with the change, [with a rare exception](https://docs.odus.com/docs/guides/api-features/webhooks.md#when-version-2-cannot-capture-the-event). * **A fixed shape you can generate types from.** Each resource has a fixed set of fields, published for every event in the [Webhooks section of the API reference](https://docs.odus.com/docs/api/core/v2/webhook-customer-created). The shape does not change when an API response changes. * **Deletion events carry the deleted resource.** Version 2 normally sends the resource as it was when it was removed. Version 1 sends only its `id`. * **An `.updated` event for every change.** On payments, customers, subscriptions, invoices, products and prices, `.updated` is sent whenever a version 2 field changes. * **Smaller payloads.** Related objects are sent as their ID instead of as expanded objects. [Payload versions](https://docs.odus.com/docs/guides/api-features/webhooks.md#payload-versions) compares the two versions in full. ## Move a subscriber to version 2 You cannot change the payload version of an existing subscriber. Run a second subscriber on version 2 next to the one you have, then deactivate the old one when the new one works. 1. **Make room for one more active subscriber.** You can have at most five active subscribers, and a new subscriber is always created active. If you already have five, deactivate one you can spare first. Don't deactivate the subscriber you are replacing yet. 2. **Prepare your endpoint for version 2.** Update your handler to read the [version 2 fields](#fields-by-resource). Until you finish the move, your endpoint receives both versions. If both subscribers use the same URL, read `profile` from the body to know which subscriber sent the request. Then verify the signature with that subscriber's shared secret. You can also give the new subscriber a different URL, for example by adding a query parameter. 3. **Create the version 2 subscriber.** Subscribe it to the same events as the old one, and set `payloadVersion` to `2` in [`POST /v2/webhook-subscribers`](https://docs.odus.com/docs/api/core/v2/webhook-subscriber-controller-create), or choose **Version 2** when you add it in the [dashboard](https://docs.odus.com/docs/guides/dashboard/webhook-subscriber.md). Save the shared secret, because it is shown only once. Creating a version 2 subscriber ``` { "recipientUrl": "https://example.com/webhooks", "subscribedTo": ["payment.succeeded", "payment.captured"], "payloadVersion": 2 } ``` 4. **Handle both versions while they overlap.** Each event is delivered to each subscriber separately, with the same `eventId`. Record the `eventId`s you have processed and skip one you have already handled, so that you act on each event once. 5. **Confirm the new subscriber works.** Check that your endpoint accepts and processes the version 2 deliveries for every event type you subscribe to. 6. **Let the old subscriber finish retrying.** Deactivating it pauses the retries of its failed deliveries. List them with [`GET /v2/webhook-deliveries`](https://docs.odus.com/docs/api/core/v2/webhook-controller-find-all-deliveries-paginated): ``` GET /v2/webhook-deliveries?subscriber[eq]=&success[eq]=false ``` Page through all of the results with the cursor each time you check, starting from the first page, because deliveries that have stopped being retried stay in the list. Repeat the check until every delivery it returns has stopped being retried: `attempts` has reached 10 or `abandonedAt` is set. A delivery that succeeds on a retry drops out of the list. Retries normally finish within about 3 days, but a delivery can stay pending for longer. 7. **Deactivate the old subscriber, at least an hour after you created the new one.** A new subscriber receives only events that happen after you create it. An earlier event that has not been sent to the old subscriber when you deactivate it is not sent to either, and the waiting time leaves room for those events to be sent. Then send `{ "active": false }` to [`POST /v2/webhook-subscribers/{id}`](https://docs.odus.com/docs/api/core/v2/webhook-subscriber-controller-update), or select **Archive** for it in the dashboard. There is no delete operation. A deactivated subscriber stays in your list. ## Fields by resource This section explains the fields of `data` on payload version 2. Each resource has a fixed set of fields, and every event about that resource uses the same set. The [Webhooks section of the API reference](https://docs.odus.com/docs/api/core/v2/webhook-customer-created) lists the fields of each event, with their types. ### How to read the fields * Every top-level field listed in the API reference is present. When the resource has no value for a field, the field is `null`. The one exception is the [rare case](https://docs.odus.com/docs/guides/api-features/webhooks.md#when-version-2-cannot-capture-the-event) where `data` contains only `id`. * Dates are strings in ISO 8601 format. * A related object is sent as its ID only, never as the expanded object. To get the full object, retrieve it from the API with that ID. A price's `billingSchedule` and a subscription's `setupLineItem` have no retrieve endpoint of their own. * `metadata` is the resource's metadata after the change that caused the event. * A field the API reference does not list is not sent. If you need it, retrieve the resource from the API. After a deletion event the resource can no longer be retrieved, so keep any field you need for handling a deletion on your side beforehand. Version 2 sends fewer fields than version 1, but it still contains customer personal data, such as email addresses, phone numbers, dates of birth and addresses. Protect webhook payloads in the same way for both versions. ### Payment Sent with every `payment.*` event. Fields: [`payment.created`](https://docs.odus.com/docs/api/core/v2/webhook-payment-created) in the API reference. `captureMethod` is the payment's capture method. In the API response, it is inside `paymentMethodOptions`. The payment response fields `displayStatus`, `cart` and `paymentMethodOptions`, and the `is…` flags such as `isCaptured`, are not sent. ### Transaction Sent with every `transaction.*` event. Fields: [`transaction.capture_failed`](https://docs.odus.com/docs/api/core/v2/webhook-transaction-capture-failed) in the API reference. ### Customer Sent with every `customer.*` event. Fields: [`customer.created`](https://docs.odus.com/docs/api/core/v2/webhook-customer-created) in the API reference. `dateOfBirth` is a date only, in `YYYY-MM-DD` format. The customer's payment methods and subscriptions are not sent. ### Blacklist entry Sent with every `blacklist.*` event. Fields: [`blacklist.added`](https://docs.odus.com/docs/api/core/v2/webhook-blacklist-added) in the API reference. `blacklist.removed` normally carries the entry as it was when it was removed. ### Subscription Sent with every `subscription.*` event. Fields: [`subscription.active`](https://docs.odus.com/docs/api/core/v2/webhook-subscription-active) in the API reference. ### Invoice Sent with every `invoice.*` event. Fields: [`invoice.created`](https://docs.odus.com/docs/api/core/v2/webhook-invoice-created) in the API reference. ### Product Sent with every `product.*` event. Fields: [`product.created`](https://docs.odus.com/docs/api/core/v2/webhook-product-created) in the API reference. ### Price Sent with every `price.*` event. Fields: [`price.created`](https://docs.odus.com/docs/api/core/v2/webhook-price-created) in the API reference. `price.deleted` normally carries the price as it was when it was deleted. ---