# Getting Started with Koard Koard supports contactless acceptance on iPhone and Android and facilitates payments to the merchant's configured processor. Choose your [iOS setup](/docs/setting-up-the-ios-sdk/installing-the-sdk) or [Android setup](/docs/setting-up-the-android-sdk/installing-the-sdk), then configure the merchant and active location. Use these guides to integrate contactless acceptance into your iOS or Android application. The Apple-specific setup below applies to Tap to Pay on iPhone; Android requirements are in the [Android SDK installation guide](/docs/setting-up-the-android-sdk/installing-the-sdk). [](https://www.koard.com/videos/apple-tap-to-pay.mp4) ## What is Koard? Koard facilitates contactless payments to the merchant's configured processor through its iOS and Android SDKs. Payment Service Providers (PSPs) and Independent Software Vendors (ISVs) can integrate acceptance into their existing applications. Koard supports SDK integration, certification coordination, and merchant onboarding. Launch timing depends on platform approval, processor configuration, and your certification scope. ## Key Benefits * **SDK Integration**: Released iOS and Android SDKs for supported devices * **Certification Support**: Use approved platform and processor configurations and complete the approvals required for your app * **Comprehensive Support**: End-to-end guidance from Apple setup to merchant onboarding * **Precompiled SDK**: Easy integration with our .xcframework distribution * **Merchant Management**: Complete portal for merchant configuration and credential management * **Payment Routing**: Flexible integration with multiple payment processors ## Who Can Use Koard? Koard is designed for: * **Payment Service Providers (PSPs)** serving multiple merchants * **Independent Software Vendors (ISVs)** building payment applications * **Large merchants** processing significant transaction volumes * **US and Europe-based enterprises** with existing POS infrastructure **Prerequisites** **Business Requirements** * US or Europe-based enterprise PSP or ISV * Serve multiple merchants or process large transaction volumes * Existing experience with physical POS terminals or similar technology * Ability to track items and orders in your own database * Capacity to update your app for Apple compliance requirements **Technical Requirements** * **iPhone Model**: iPhone XS or later * **iOS Version**: iOS 17.4 or later * **Apple Developer Account**: Organization-level account required * **GitHub Account**: Required for SDK dependency management * **Supported PSP**: Integration with a Koard-supported Payment Service Provider * **[Sandbox Apple Account](/docs/appendix/glossary#sandbox-apple-account "Dedicated Apple tester account on a test iPhone for certification and QA.")**: Dedicated Sandbox tester signed in on a test iPhone that will be used for certification and QA **Test Device Required**: Plan for a dedicated test iPhone running iOS 17.4 or later with your Sandbox Apple Account signed in. Production Apple IDs cannot be used for Sandbox testing. ## Integration Phases ### Phase 1: Apple Partnership Setup * Establish relationship with Apple through Koard * Confirm the platform and processor certification requirements for your scope * Set up [Apple Business Register](/docs/appendix/glossary#apple-business-register "Apple hub for PSP Tap to Pay documentation, registration, and certification.") Account * Configure environment and [KEK exchange](/docs/appendix/glossary#kek-exchange "Key-encryption-key setup used in Apple partnership / environment configuration.") processes ### Phase 2: Merchant Onboarding * Create merchant configurations via Koard's portal * Set up [merchant credentials](/docs/appendix/glossary#merchant-credentials "Credentials used to authenticate the Koard SDK; scoped to locations and terminals.") and processor integrations * Upload [VAR sheets](/docs/appendix/glossary#var-sheet "Processor-provided credential packet required to board a terminal.") for supported processors and gateways * Configure payment routing with chosen processors ### Phase 3: SDK Integration * Integrate Koard's precompiled .xcframework * Implement payment flows in your iOS application * Test transactions in certification environment * Prepare for production deployment ### Phase 4: Launch and Scale * Deploy to production environment * Onboard merchants and provision iPhone terminals * Monitor transactions and optimize performance * Scale your tap-to-pay business ## Quick Start Path 1. **Verify Prerequisites**: Confirm you meet all business and technical requirements 2. **Contact Koard**: Reach out to our team to discuss your integration needs 3. **Set Up Apple Partnership**: Work with Koard to establish your Apple relationship 4. **Configure Merchants**: Use our portal to set up your merchant configurations 5. **Integrate SDK**: Follow our technical integration guide 6. **Test and Launch**: Complete testing and go live with tap-to-pay payments ## Active Location in App Settings Your app must let operators select an active location in settings. That location determines the linked terminal and payment routing for the authenticated merchant. Validate the selection after login or a merchant change and show it before taking payments. See [Active Location and Terminal Routing](/docs/getting-started-with-koard/setting-up-the-merchant#active-location-and-terminal-routing). ## Next Steps * [Setting up the Merchant](/docs/getting-started-with-koard/setting-up-the-merchant) - Learn how to configure merchant accounts and credentials * [Creating a Sandbox Apple Account](/docs/setting-up-the-ios-sdk/creating-a-sandbox-apple-account) - Configure dedicated testers and devices * [Installing the SDK](/docs/setting-up-the-ios-sdk/installing-the-sdk) - Technical integration guide * [Payment Lifecycle](/docs/payments/payment-lifecycle) - Understand the complete payment flow * [Getting Ready for Production](/docs/setting-up-the-ios-sdk/getting-ready-for-production) - Prepare schemes and [API keys](/docs/appendix/glossary#api-key "Secret header credential for REST calls; v5 keys use scoped permissions.") for launch ## Support For questions or assistance with your integration: * **Technical Support**: Contact our development team * **Business Inquiries**: Reach out to Behailu at * **PSP Partnerships**: Ask about our updated list of supported Payment Service Providers # Setting up the Merchant Learn how to set up merchant accounts in the Koard [Merchant Management System (MMS)](/docs/appendix/glossary#mms "Koard portal for merchants, terminals, locations, credentials, and API keys.") and configure them for tap-to-pay payments. [![link icon](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLXNxdWFyZS1jb2RlLWljb24gbHVjaWRlLXNxdWFyZS1jb2RlIj48cGF0aCBkPSJtMTAgOS0zIDMgMyAzIi8+PHBhdGggZD0ibTE0IDE1IDMtMy0zLTMiLz48cmVjdCB4PSIzIiB5PSIzIiB3aWR0aD0iMTgiIGhlaWdodD0iMTgiIHJ4PSIyIi8+PC9zdmc+) Access UAT MMS ](https://app.uat.koard.com)[ Access Production MMS](https://app.koard.com) For the safety and security of your merchant accounts, complete our [merchant checklist](#verification-and-testing) before going live. Immediately after you create a merchant account, you can use it in testing environments. In a _sandbox_ (A sandbox is an isolated test environment that allows you to test Koard functionality without affecting your live integration. Use sandboxes to safely experiment with new features and changes), simulate transactions and use all of Koard's features without moving any money. To accept real payments, you must activate your merchant account to use live mode. **Prerequisites** Before you begin, ensure you have: * **Access to the Koard Merchant Management System** * **Valid business registration documents** * **Tax identification number** for each merchant * **[Merchant Category Code (MCC)](/docs/appendix/glossary#mcc "Four-digit ISO business-type code required at merchant onboarding.")** for each business * **Processor credentials** ([MID](/docs/appendix/glossary#mid "Processor-assigned merchant identifier used for routing and batching."), [TID](/docs/appendix/glossary#tid "Processor-assigned per-terminal identifier; batches are typically unique per MID/TID."), [VIN](/docs/appendix/glossary#vin "Vendor identifier from some processors, entered with MID/TID at terminal setup.")) or [Partner ID](/docs/appendix/glossary#partner-id "Alternate boarding identifier used by processors like Payroc instead of MID/TID/VIN.") * **[Sandbox Apple Account](/docs/appendix/glossary#sandbox-apple-account "Dedicated Apple tester account on a test iPhone for certification and QA.") on dedicated test iPhone** for validating [Tap to Pay](/docs/appendix/glossary#tap-to-pay "Apple contactless acceptance on iPhone without extra hardware.") flows **Dedicated Test Hardware**: Make sure your Sandbox Apple Account is signed in on a separate test iPhone. You'll need it to validate Tap to Pay flows before onboarding merchants in production. ## Access the Merchant Management System ### UAT Environment For testing and development, use the [UAT](/docs/appendix/glossary#uat "Isolated sandbox environment — transactions do not move live money.") MMS: **** ### Production Environment For live merchant onboarding: **** ## Create a Merchant To create a merchant, fill out the merchant application requesting basic information about the business, processor details, and location information. After creating the merchant, you can immediately start configuring terminals and processing payments. Koard's merchant onboarding requirements ensure compliance with payment processor regulations and Apple's tap-to-pay guidelines. These requirements come from our financial partners and Apple, and are intended to prevent abuse of the payment system. We review the information you provide internally to make sure that it complies with our merchant agreement. After you create a merchant account, you can't change its country. If you need to use Koard in a different country that we support, you must create a new merchant account. Privacy and security are priorities for Koard. Our merchant data handling follows industry standards for payment processing and Apple's security requirements. **1. Navigate to Accounts** 1. Log into the MMS 2. Go to the **Accounts** tab 3. You'll see a list of existing Partners 4. Click [![link icon](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Ik01IDEyaDE0Ii8+PHBhdGggZD0iTTEyIDV2MTQiLz48L3N2Zz4=) Add Account](#) ![getting-started-1](/getting-started-1.png) **2. Fill Required Fields** Every merchant requires these mandatory fields: * **Name**: Business name or legal entity name * **Tax ID**: Unique tax identification number (merchants with the same Tax ID will be grouped together) * **MCC Code**: 4-digit Merchant Category Code * **HQ Country**: Headquarters country location ![getting-started-2](/getting-started-2.png) **3. Save Merchant** Click **Save** to create the merchant account. ## Assign a Terminal **1. Create New Terminal** 1. After creating the merchant, click [![link icon](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxjaXJjbGUgY3g9IjEyIiBjeT0iMTIiIHI9IjEwIi8+PHBhdGggZD0iTTggMTJoOCIvPjxwYXRoIGQ9Ik0xMiA4djgiLz48L3N2Zz4=) New Terminal](#) 2\. You'll see the terminal configuration view ![getting-started-3](/getting-started-3.png) **2. Configure Terminal Details** For most processors, enter: * **MID**: Merchant ID from your processor * **TID**: Terminal ID from your processor * **VIN**: Vendor ID from your processor For processors like Payroc where merchants have their own ID: * Replace MID, TID, and VIN with the **Partner ID** (or Processing Terminal ID) 1. Configure the terminal details by clicking [![link icon](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Ik0xMSA0SDRhMiAyIDAgMCAwLTIgMnYxNGEyIDIgMCAwIDAgMiAyaDE0YTIgMiAwIDAgMCAyLTJ2LTciLz48cGF0aCBkPSJNMTguNSAyLjVhMi4xMjEgMi4xMjEgMCAwIDEgMyAzTDEyIDE1bC00IDEtMS00IDkuNS05LjV6Ii8+PC9zdmc+) Edit Terminal](#) ![getting-started-4](/getting-started-4.png) **3. Assign Location** 1. Go back to the merchant account to access the **Locations** tab ![locations-1](/locations-1.png) 2. Click [![link icon](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxjaXJjbGUgY3g9IjEyIiBjeT0iMTIiIHI9IjEwIi8+PHBhdGggZD0iTTggMTJoOCIvPjxwYXRoIGQ9Ik0xMiA4djgiLz48L3N2Zz4=) New Location](#) 3. Assign the terminal to that location 4. This gives the merchant a physical location to accept payments from ![getting-started-5](/getting-started-5.png) ## Create Merchant Credentials Your merchants will use these credentials to authenticate with the Koard SDK and process payments. The credentials are tied to specific locations and terminals, ensuring secure payment processing. **1. Generate Credentials** 1. Navigate to the merchant's credential section 2. Click **Create [Merchant Credentials](/docs/appendix/glossary#merchant-credentials "Credentials used to authenticate the Koard SDK; scoped to locations and terminals.")** 3. Generate a unique **Code** and **PIN** for the merchant ![getting-started-6](/getting-started-6.png) **2. SDK Integration** Merchants can use these credentials to log into the SDK. This Swift example targets iOS SDK 1.0.21; follow [iOS Running Payments](/docs/setting-up-the-ios-sdk/running-payments#authenticate-the-merchant) or [Android authentication](/docs/setting-up-the-android-sdk/installing-the-sdk#step-7-authenticate-merchant) for the full platform flow: ```swift import KoardSDK // After SDK initialization, call from an async function or Task. try await KoardMerchantSDK.shared.login( code: "YOUR_CODE", pin: "YOUR_PIN" ) ``` **3. Location Configuration** * Merchants can set their location from the SDK or your mPOS app * Location is required to create a card reader session on the iPhone * This ensures payments are processed at the correct merchant location **Security Note**: Keep merchant credentials confidential. Store them securely on your servers and never share them in client-side code or public repositories. ## Multiple Processor Support Each merchant can be configured with multiple MIDs based on the supported processors you have access to: * **Primary Processor**: Main payment processor for the merchant * **Secondary Processors**: Backup or specialized processors * **Regional Processors**: Location-specific payment processing ## Verification and Testing **1. Verify Configuration** 1. Check that all required fields are completed 2. Verify processor credentials are correct 3. Ensure location is properly assigned **2. Test Integration** 1. Use the UAT environment to test merchant login 2. Verify terminal assignment works correctly 3. Test payment processing with test cards **3. Go Live** 1. Move merchant to production environment 2. Update SDK credentials for production 3. Monitor initial transactions ## Keep your merchant accounts safe After you set up your merchant accounts, you'll want to keep them secure. Here are our recommendations: * **Keep private information private**: Don't share merchant credentials and keep your secret [API keys](/docs/appendix/glossary#api-key "Secret header credential for REST calls; v5 keys use scoped permissions.") confidential on your own servers. As a reminder, Koard employees will never ask you for your keys. * **Use unique credentials**: Generate unique codes and PINs for each merchant. If you reuse credentials across merchants and one account is compromised, an attacker could access multiple merchant accounts. * **Use team members to provide others with access**: You can invite others (with limited access) to your Koard MMS account so that they can log in and take certain actions without full administrative access. * **Update your computer and browser regularly**: We recommend configuring your computer to automatically download and install updates. This helps protect your system against automated attacks and malware. * **Beware of phishing**: All genuine Koard sites use the `koard.com` domain and HTTPS. If you get an email from us that you don't expect, go directly to our site to log in. Don't enter your password after clicking a link in an email. * **Enable two-factor verification**: When you enable two-factor authentication, you'll need to provide an additional unique code from your mobile device to complete the login process. This means that even if someone steals your username and password, they won't be able to log in. ## Best Practices * **Unique Tax IDs**: Ensure each merchant has a unique tax identification * **Proper MCC Codes**: Use accurate Merchant Category Codes for compliance * **Location Accuracy**: Verify physical locations match processor records * **Credential Security**: Store merchant credentials securely * **Regular Audits**: Periodically review merchant configurations ## Troubleshooting ### Common Issues * **Duplicate Tax IDs**: Merchants with same Tax ID will be grouped together * **Invalid MCC Codes**: Ensure 4-digit codes are valid for your region * **Processor Mismatch**: Verify processor credentials match your configuration * **Location Errors**: Ensure locations are properly assigned to terminals ### Support For technical issues with merchant setup: * Check the [Resources](/docs/appendix/resources) section * Contact Koard support for processor-specific issues * Review [Apple Best Practices](/docs/appendix/apple-best-practices-and-guidelines) for compliance ## See also * [iOS SDK Installation](/docs/setting-up-the-ios-sdk/installing-the-sdk) - Install and integrate the Koard Merchant SDK into your iOS application * [Tap to Pay Configuration](/docs/setting-up-the-ios-sdk/adding-support-for-tap-to-pay-on-iphone) - Configure Tap to Pay on iPhone functionality in your app * [Webhook Setup](/docs/webhooks/setting-up-webhooks) - Configure webhooks to receive real-time payment event notifications * [Payment Testing](/docs/setting-up-the-ios-sdk/running-payments) - Learn how to process and test payments with the Koard SDK * [Apple Best Practices](/docs/appendix/apple-best-practices-and-guidelines) - Follow Apple's guidelines and best practices for Tap to Pay development ## Active Location and Terminal Routing **Your app must provide a way to select the active location in settings.** Authenticate the merchant, load that merchant's available locations, and let the operator select the location where the device is taking payments. The selected `location_id` resolves the linked `terminal_id`, whose processor configuration, MID, and TID determine payment routing for that merchant. Show the active location in settings and on the payment screen. Select it before reader preparation or any payment. If the operator changes merchants, clear the previous selection and reload the new merchant's locations. If you remember a choice between sessions, validate that it still belongs to the authenticated merchant and still has a usable linked terminal. Do not silently reuse another merchant's location or select an arbitrary terminal. * **iOS:** call `KoardMerchantSDK.shared.setActiveLocationID(location.id)`, then follow the account-linking and reader preparation flow. * **Android:** call the suspending `sdk.setActiveLocation(location.id)`, then `sdk.enrollDevice()` and the reader preparation flow. Block payment initiation until location selection and required readiness checks have completed. If a location's terminal changes, reload the configuration and complete the SDK's readiness flow before taking another payment. # Setting up the Merchant via API Create a merchant account, board its terminal, associate a location, and issue merchant SDK login credentials. Use a backend API key with the permissions and account hierarchy access required by each endpoint. Use `https://api.uat.koard.com` and UAT processor credentials for testing. Production uses `https://api.koard.com` with separate keys and assigned merchant identifiers. Example values are placeholders, not certification credentials. ## 1. Select an Available Processor Configuration ```bash curl 'https://api.uat.koard.com/v1/processor-configs' \ -H 'X-Koard-apikey: YOUR_API_KEY' ``` Choose the configuration intended for the merchant and environment. Match its boarding format to the [processor guide](/docs/boarding-a-merchant/processor-configurations). A configuration ID for one processor cannot be combined with another processor's VAR sheet. ## 2. Create the Merchant Account Use the **merchant** request variant. `tax_id` and `mcc` are required for merchants and must be omitted when creating a partner. See [Creating Merchant and Partner Accounts](/docs/getting-started-with-koard/creating-accounts) for the differences and parent-account rules. ```bash curl -X POST 'https://api.uat.koard.com/v2/accounts' \ -H 'X-Koard-apikey: YOUR_API_KEY' \ -H 'Content-Type: application/json' \ -d '{ "type": "merchant", "name": "Example Coffee", "description": "Retail coffee shop", "address": { "street_line1": "123 Main Street", "city": "New York", "state": "NY", "zip": "10016", "country": "USA" }, "tax_id": "12-3456789", "mcc": "5812", "available_processor_configs": ["YOUR_TSYS_PROCESSOR_CONFIG_ID"] }' ``` The response is **201 Created**. Store the returned account `id` as `YOUR_MERCHANT_ACCOUNT_ID`. Merchant creation requires `type`, `name`, `tax_id`, `mcc`, and a US address with `city`, `state`, and `zip`. `description` is optional. Processor access must come from configurations your account can assign. Account creation does **not** automatically issue a REST API key. If the merchant needs one, create it separately through [API Key Management](/api-reference/api-key-management) with the required permissions. An API key and merchant SDK login credentials are different credentials. ## 3. Board the Terminal This is a TSYS example. Replace the processor configuration ID and all VAR-sheet identifiers with assigned values; retain leading zeros. Other processors use their own request format. ```bash curl -X POST 'https://api.uat.koard.com/v2/terminals' \ -H 'X-Koard-apikey: YOUR_API_KEY' \ -H 'Content-Type: application/json' \ -d ' { "account_id": "YOUR_MERCHANT_ACCOUNT_ID", "processor_config_id": "YOUR_TSYS_PROCESSOR_CONFIG_ID", "name": "Front Counter POS", "var_sheet": { "acquirer_bin": "123456", "merchant_number": "123456789012", "store_number": "0001", "terminal_number": "0002", "merchant_category_code": "5812", "merchant_name": "Example Coffee", "merchant_location": "New York", "merchant_state": "NY", "city_code": "10016", "acceptor_street_address": "123 Main Street", "industry_code": "R", "acceptor_phone": "2125550100", "acceptor_customer_service_phone": "2125550101", "currency_code": "840", "country_code": "USA", "language_indicator": "00", "time_zone_diff": "705" } } ' ``` The response is **201 Created** and contains the boarded terminal. Store its `terminal_id`. Use `name`, not `terminal_name`, in the request. A boarded terminal is distinct from an Apple reader terminal profile. ## 4. Create or Update the Location ```bash curl -X POST 'https://api.uat.koard.com/v1/locations' \ -H 'X-Koard-apikey: YOUR_API_KEY' \ -H 'Content-Type: application/json' \ -d '{ "account_id": "YOUR_MERCHANT_ACCOUNT_ID", "name": "Main Store", "terminal_id": "YOUR_RETURNED_TERMINAL_ID", "processor_config_id": "YOUR_TSYS_PROCESSOR_CONFIG_ID", "address": { "street_line1": "123 Main Street", "city": "New York", "state": "NY", "zip": "10016", "country": "USA" } }' ``` Store the returned location `id`. For an existing location, use `PUT /v1/locations/{location_id}` and send only changed fields, such as `terminal_id`. Keep the location and terminal on the same merchant account and use the intended processor configuration. ## 5. Issue Merchant SDK Credentials ```bash curl -X POST 'https://api.uat.koard.com/v1/accounts/credentials' \ -H 'X-Koard-apikey: YOUR_API_KEY' \ -H 'Content-Type: application/json' \ -d '{"account_id": "YOUR_MERCHANT_ACCOUNT_ID"}' ``` The response is **201 Created**. It contains `code` and a plaintext `pin`. Deliver them through a trusted channel. The plaintext PIN is returned on creation only; subsequent credential reads return metadata and the code, not the PIN. - List active credentials: `GET /v1/accounts/{account_id}/credentials`. - Read one credential: `GET /v1/accounts/credentials/{credential_id}`. ## 6. Select the Active Location in Your App **Include a location selector in app settings.** After SDK login, load the authenticated merchant's locations and set the operator's selected `location_id`. That location's linked terminal controls which processor configuration, MID, and TID are used for payments. Do not pass a terminal ID in place of a location ID or reuse a selection from another merchant. See [Active Location and Terminal Routing](/docs/getting-started-with-koard/setting-up-the-merchant#active-location-and-terminal-routing), then complete the platform's linking, enrollment, and reader preparation steps before taking a test payment. # Creating Merchant and Partner Accounts Use `POST /v2/accounts` to create a **merchant** or **partner** under your account hierarchy. Your authenticated account must be a PSP or partner with access to the intended parent. A merchant account cannot create child accounts. Choose the matching request variant in the [Accounts API reference](/api-reference/accounts). Account creation returns **201 Created** and the new account's `id`. Issue a REST API key separately when needed through [API Key Management](/api-reference/api-key-management). | Field | Merchant creation | Partner creation | |-------|-------------------|------------------| | `type` | `merchant` | `partner` | | `name` | Required business name | Required partner name | | `description` | Optional | Optional | | `tax_id` | Required merchant tax ID | Omit | | `mcc` | Required four-digit merchant category code | Omit | | `address` | Required US business address with `city`, `state`, and `zip` | Optional | | `surcharge_rate`, `surcharge_basis`, `surcharge_confirmation_required` | Optional merchant defaults | Omit | | `tax_rate`, `tax_basis` | Optional merchant defaults | Omit | | `parent_id` | Optional accessible parent account | Optional accessible parent account | | `available_processor_configs` | Optional assigned configuration IDs | Optional assigned configuration IDs | When `parent_id` is omitted, the caller's account becomes the parent. An explicit parent must be your account or an accessible descendant and must be able to have children. Merchant accounts cannot be parents. Omitting `available_processor_configs` inherits the parent's configuration access. Supply assigned IDs from the same environment when narrowing access; do not use an empty list to mean “no processor access.” ## Create a Merchant Send this JSON body to `POST /v2/accounts` with your backend API key: ```json { "type": "merchant", "name": "Example Coffee", "tax_id": "12-3456789", "mcc": "5812", "address": { "street_line1": "123 Main Street", "city": "New York", "state": "NY", "zip": "10016", "country": "USA" } } ``` The merchant tax ID, MCC, and address requirements apply in addition to the shared request's `type` and `name`. A missing or empty tax ID, missing MCC, or incomplete merchant address is rejected. The country defaults to the US when omitted; boarding is currently US only. Merchant defaults configure this merchant's payment behavior and do not configure its parent. Continue with [Setting up the Merchant via API](/docs/getting-started-with-koard/setting-up-the-merchant-via-api) to board a terminal, associate a location, and issue SDK credentials. ## Create a Partner Send this JSON body to the same endpoint: ```json { "type": "partner", "name": "Example Software Partner", "description": "Manages descendant merchant accounts" } ``` A partner organizes descendant accounts. Omit `tax_id`, `mcc`, surcharge settings, and tax-default fields. These fields describe a merchant's payment configuration. Create each merchant as its own account under the partner and board terminals for that merchant. ## Retrieve Accounts Use `GET /v2/accounts` to list accounts beneath the authenticated account. Add `sort_type=hierarchy` to return descendants nested under `children`. The caller's credentials determine the account scope. Keep the account `id` returned during creation and use `GET /v2/accounts/{account_id}` to retrieve it later. This lookup checks access to the requested account and returns `404` for an inaccessible account. See the [Accounts API reference](/api-reference/accounts) for the supported list filters and response fields. # Available processor configurations Every terminal boarding request needs a `processor_config_id`. This identifies the processor configuration your merchant will use. Choose an ID returned for your account in the environment where you are boarding the terminal. ## List available configurations Call `GET /v1/processor-configs` with your Koard API key. The response is a list of active configurations available to your account. | Environment | API URL | |---|---| | UAT | `https://api.uat.koard.com` | | Production | `https://api.koard.com` | UAT request: ```bash curl "https://api.uat.koard.com/v1/processor-configs" \ --header "X-Koard-apikey: YOUR_UAT_API_KEY" ``` For production, use the production URL and your production API key. The fields you need to choose a configuration are: | Field | How to use it | |---|---| | `id` | Send this value as `processor_config_id` in your terminal boarding request. | | `name` | Identify the configuration you intend to use. | For example, one UAT TSYS result contains these fields; other response fields are omitted here: ```json { "id": "dcc6d302-2406-4973-b555-d070e66e6adb", "name": "TSYS" } ``` ## Use the configuration when boarding 1. List the configurations available to your account. 2. Choose the configuration for your processor and environment. 3. Send its `id` as `processor_config_id` in `POST /v2/terminals`, together with your merchant's `account_id` and the fields in the processor's boarding guide. The top of each [processor boarding guide](/docs/boarding-a-merchant/contracts#choose-a-request-format) lists its UAT and production IDs. The request examples use UAT IDs where available. Use a production ID for production requests. **TSYS without PIN and TSYS with PIN are separate configurations with different IDs.** Select the configuration for the service you need. ## Configuration access Contact [support@koard.com](mailto:support@koard.com) for access to **TSYS with PIN** or **Silverflow**. For **production access to processors other than Fiserv Nashville/Cybersource**, contact the same address. An ID shown in a guide must also appear in the configurations available to your account. If the configuration you need is missing from the response, contact support to request access. # Terminal boarding Create a terminal with `POST /v2/terminals` using the merchant's account ID, a processor configuration ID, and the identifiers assigned by the processor. 1. [List available processor configurations](/docs/boarding-a-merchant/processor-configurations) with `GET /v1/processor-configs` using your API key for the target environment. 2. Use the request format for the configuration assigned to your merchant. 3. Send your API key in the `X-Koard-apikey` header. 4. Save the `terminal_id` returned by the API. Use it as `{terminal_id}` when reading or updating the terminal. ## Common fields | Field | What to send | |---|---| | `account_id` | The merchant's Koard account ID | | `processor_config_id` | A configuration ID returned by `GET /v1/processor-configs` | | `name` | Terminal display name; `terminal_name` is also accepted. Some configurations require the merchant's business name. | | `description` | Optional description; `terminal_description` is also accepted | | `mid` | Assigned merchant identifier, where required by the selected configuration | | `tid` | Assigned terminal identifier, where required by the selected configuration | | `mcc` | Four-digit merchant category code | | `var_sheet` | Processor-specific details described in the boarding guide for your configuration | Keep identifiers as strings and preserve leading zeros. Use the fields in the request example rather than copying a terminal response into a create request. ## Choose a request format | Configuration | Boarding details | |---|---| | [TSYS / TSYS with PIN / Netevia](/docs/boarding-a-merchant/tsys) | Assigned TSYS VAR-sheet fields | | [Payroc](/docs/boarding-a-merchant/payroc) | Top-level `mid` and `tid`; no VAR sheet | | [Elavon](/docs/boarding-a-merchant/elavon) | Full `tid`, or bank and terminal numbers in `var_sheet` | | [Worldpay](/docs/boarding-a-merchant/worldpay) | Top-level `mid`, `tid`, `mcc`, and `var_sheet.bank_id` | | [Fiserv](/docs/boarding-a-merchant/fiserv/fiserv-overview) | Assigned MID/TID/MCC and the VAR-sheet format for your configuration | | [Silverflow](/docs/boarding-a-merchant/silverflow) | Create an acceptor with a BIN key, or use an existing acceptor belonging to the merchant | | [Cybersource](/docs/boarding-a-merchant/cybersource) | Assigned `tid` and the merchant's existing credentials in `var_sheet` | Each processor guide lists UAT and production configuration IDs at the top. Examples use UAT IDs where available; use the production ID for production requests. Merchant account IDs, credentials and other merchant details are fictional placeholders. TSYS without PIN and TSYS with PIN require different configuration IDs. Contact [support@koard.com](mailto:support@koard.com) for TSYS with PIN or Silverflow access, and for production access to processors other than Fiserv Nashville/Cybersource. ## Addresses Account and location addresses use `street_line1`, `street_line2`, `city`, `state`, `zip`, and `country`. Most terminal VAR sheets use `merchant_street_address`, `merchant_street_address2`, `merchant_city`, `merchant_state`, `merchant_postal_code`, and `country_code`. TSYS uses `acceptor_street_address` for the street, `merchant_location` for the city, and `city_code` for the ZIP. Use `USA` for a US address. `US` and `840` are also accepted as US country values. On create, an omitted or null country defaults to `USA`. On update, omit country to keep the current value, or send null to set it to `USA`. Elavon VAR sheets also accept `0840`. Use a two-letter state code and a five-digit ZIP or ZIP+4. Address requirements vary by processor. Boarding is currently available only in the US. ## Update a terminal Use `PUT /v2/terminals/{terminal_id}`, replacing `{terminal_id}` with the Koard ID returned when you created the terminal. This is different from the processor's `tid`. There is no `PUT /v2/terminals` without an ID. Use an API key with `terminals:edit` and access to the terminal's merchant account. A merchant can update its own terminals; PSPs and partners can update terminals belonging to merchants in their account hierarchy. Send only the fields you want to change. All request fields are optional. Omitted fields retain their current values, so you do not need to resend the create request or the complete terminal response. ```sh curl -X PUT 'https://api.uat.koard.com/v2/terminals/YOUR_TERMINAL_ID' \ -H 'X-Koard-apikey: YOUR_API_KEY' \ -H 'Content-Type: application/json' \ -d '{"name":"Front Counter POS","mcc":"5812"}' ``` | Field | Update behavior | |---|---| | `name`, `description` | Change the terminal display name or description. A null description clears it. | | `mid`, `tid`, `store_number`, `bin`, `vid`, `agent_bank_number` | Change assigned processor identifiers. Keep them as strings and preserve leading zeros. | | `mcc` | Change the four-digit merchant category code. Some configurations require it and reject null. | | `terminal_capability`, `currency_code`, `country_code`, `status` | Change terminal settings. Omitted country is preserved; null country becomes `USA`. | | `processor_config_id` | Switch to an available configuration. This may perform boarding with the new configuration. Omission or null retains the current configuration. | | `surcharge_rate`, `surcharge_confirmation_required`, `surcharge_basis`, `tax_rate`, `tax_basis` | Change terminal defaults. Percentage values are percentages, such as `3.5` for 3.5%. Null allows the applicable location or account default to take effect. | | `var_sheet` | Merge supplied processor-specific fields with the current VAR sheet. Use the field names in the configuration's boarding guide. | | `batch_schedule` | For configurations with editable scheduling, send an object to set the schedule or null to remove it. Omission preserves it. Managed and unsupported schedules cannot be changed through this field. | For an address-only change, send just the changed VAR-sheet fields. For example, on configurations using `merchant_street_address2`: ```json { "var_sheet": { "merchant_street_address2": "Suite 300" } } ``` Omitted address fields keep their current values. Omit `processor_config_id` when making an address-only change. A `var_sheet` object merges supplied keys; `var_sheet: null` requests a clear and is rejected when it would remove required processor details. Null is not interchangeable with omission. Unknown fields are rejected. Do not include read-only response fields such as `terminal_id`, `account_id`, timestamps, or `processor_details` in the body. A successful update returns HTTP 200 and the complete updated terminal configuration. Read it again with `GET /v2/terminals/{terminal_id}`. See the [Terminals API reference](/api-reference/terminals) for the request schema and response fields. # Silverflow terminal boarding ## Processor configurations Boarding requires a `processor_config_id` available to your account in the same environment. [List available configurations](/docs/boarding-a-merchant/processor-configurations) before choosing an ID. | Configuration | UAT `processor_config_id` | Production `processor_config_id` | | ------------- | -------------------------------------- | -------------------------------- | | Silverflow | `0273faf8-8ce5-454e-b7ac-9e29732fffff` | Not listed | For access to Silverflow, contact . If an ID is not listed, contact for the appropriate configuration. Examples with listed IDs use UAT. For production, use the production API URL, API key and configuration ID. Select your Silverflow configuration from `GET /v1/processor-configs`. Use `POST /v2/terminals` to create a terminal with a new acceptor or connect one belonging to the merchant. ## Create an acceptor Send top-level `account_id`, `processor_config_id`, `mid`, and `mcc`. In `var_sheet`, provide `merchant_name` and a `bin_key` available from `GET /v1/silverflow/bins`. The merchant account must already contain its business name, address and tax details. The merchant name defaults to the top-level `merchant_name`, then `name`, then the account name if `var_sheet.merchant_name` is omitted. Use `point_of_sale_address` for the physical payment location when it differs from the merchant account, as shown in the create-acceptor example below. The flat `merchant_*` address fields described in [Terminal boarding](/docs/boarding-a-merchant/contracts#addresses) are also accepted. ## Use an existing acceptor Supply `var_sheet.acceptor_key` for an acceptor belonging to this merchant. You do not need to repeat its [MID](/docs/appendix/glossary#mid "Processor-assigned merchant identifier used for routing and batching."), [MCC](/docs/appendix/glossary#mcc "Four-digit ISO business-type code required at merchant onboarding.") or BIN key. Omit `var_sheet.merchant_name` and the address to keep the acceptor's existing values. If it has no address, the merchant account address is used. ## Request examples Send these request bodies to `POST /v2/terminals` with your Koard API key in the `X-Koard-apikey` header. Processor configuration IDs are listed by environment above. Merchant account IDs, credentials and merchant details below are fictional placeholders; replace them with your merchant's values. Preserve leading zeros. ### Silverflow: create an acceptor UAT example. Select an available bin\_key from GET /v1/silverflow/bins. Supply the merchant name, mid and mcc. The merchant account must contain its business, address and tax details. ```json { "account_id": "00000000-0000-4000-8000-000000000001", "processor_config_id": "0273faf8-8ce5-454e-b7ac-9e29732fffff", "name": "Example Coffee", "mid": "YOUR_ASSIGNED_MID", "mcc": "5812", "var_sheet": { "bin_key": "bin-YOUR_ASSIGNED_BIN", "merchant_name": "Example Coffee", "point_of_sale_address": { "line1": "123 Main Street", "line2": "Suite 200", "city": "New York", "stateOrProvince": "NY", "postalCode": "10016", "countryCode": { "alpha3": "USA" } } } } ``` ### Silverflow: use an existing acceptor UAT example. Supply an acceptor\_key belonging to this merchant. Omit the merchant name and address to keep the existing values. ```json { "account_id": "00000000-0000-4000-8000-000000000001", "processor_config_id": "0273faf8-8ce5-454e-b7ac-9e29732fffff", "name": "Example Coffee", "var_sheet": { "acceptor_key": "mac-YOUR_ACCEPTOR_KEY" } } ``` ## Update the terminal Use `PUT /v2/terminals/{terminal_id}` with only the changed fields. For example: ```json { "var_sheet": { "merchant_name": "Example Coffee Downtown" } } ``` `var_sheet.name` is also accepted for the merchant name. If both are supplied, `merchant_name` takes precedence. A blank or null merchant name is rejected. A top-level `name` changes the terminal's display name. # Cybersource terminal boarding ## Processor configurations Boarding requires a `processor_config_id` available to your account in the same environment. [List available configurations](/docs/boarding-a-merchant/processor-configurations) before choosing an ID. | Configuration | UAT `processor_config_id` | Production `processor_config_id` | |---|---|---| | Cybersource existing credentials | `18083156-5ce9-4147-bf7c-3f792d010dc7` | `212ef266-1f7d-4dd3-a089-ee608473877c` | Examples with listed IDs use UAT. For production, use the production API URL, API key and configuration ID. Connect a terminal using your merchant's existing Cybersource credentials. Select the configuration assigned to your merchant from `GET /v1/processor-configs`, then create the terminal with `POST /v2/terminals`. ## Request fields | Field | What to send | |---|---| | `account_id` | Merchant's Koard account ID | | `processor_config_id` | Configuration ID assigned for this merchant's Cybersource credentials | | `tid` | Assigned terminal identifier | | `name` | Optional terminal display name | | `description` | Optional terminal description | | `var_sheet.merchant_id` | Cybersource merchant ID | | `var_sheet.api_key_id` | Cybersource API key ID | | `var_sheet.secret_key` | Cybersource secret key | The three credential fields are required and cannot be blank. Supply the merchant ID in `var_sheet.merchant_id`. Your Koard API key authenticates the request in the `X-Koard-apikey` header. The Cybersource credentials belong in `var_sheet`. ## Request examples Send these request bodies to `POST /v2/terminals` with your Koard API key in the `X-Koard-apikey` header. Processor configuration IDs are listed by environment above. Merchant account IDs, credentials and merchant details below are fictional placeholders; replace them with your merchant's values. Preserve leading zeros. ### Cybersource: existing merchant credentials UAT example. Supply the assigned terminal identifier and the merchant's existing Cybersource merchant ID, API key ID and secret key. ```json { "account_id": "00000000-0000-4000-8000-000000000001", "processor_config_id": "18083156-5ce9-4147-bf7c-3f792d010dc7", "name": "Example Coffee", "tid": "YOUR_ASSIGNED_TID", "var_sheet": { "merchant_id": "YOUR_MERCHANT_ID", "api_key_id": "YOUR_API_KEY_ID", "secret_key": "YOUR_SECRET_KEY" } } ``` See [Terminal boarding](/docs/boarding-a-merchant/contracts) for common fields and the terminal response. # TSYS terminal boarding ## Processor configurations Boarding requires a `processor_config_id` available to your account in the same environment. [List available configurations](/docs/boarding-a-merchant/processor-configurations) before choosing an ID. | Configuration | UAT `processor_config_id` | Production `processor_config_id` | | ------------- | ------------------------------------------------------------------------------------------------ | -------------------------------------- | | TSYS (no PIN) | `dcc6d302-2406-4973-b555-d070e66e6adb` TSYS AWS (no PIN): `c614b77b-875f-403a-b7f6-5aaea09b340c` | `0571c931-c150-4520-ba18-5466ce52929b` | | TSYS with PIN | `1f402cde-38c2-43fa-a665-c7a0440f5051` | `a9ca2524-5bff-4778-8833-11ee3aad4d0d` | | Netevia | `8363f0d7-10b0-4b90-add1-5b9ed6b01e5d` | `fea3db59-3964-4f23-8ad7-a907e147f993` | For access to TSYS with PIN, contact . For production access to TSYS (no PIN) and Netevia, contact . Examples with listed IDs use UAT. For production, use the production API URL, API key and configuration ID. Use `POST /v2/terminals` with the merchant's `account_id`, the assigned `processor_config_id`, and a `var_sheet` containing the TSYS values below. `name` is an optional terminal display name; `terminal_name` is also accepted. **TSYS (no PIN) and TSYS with PIN are different configurations.** Use the corresponding `processor_config_id` from the table above; they have separate request examples below. TSYS with PIN access requires contacting . The same VAR-sheet fields apply to TSYS with PIN and Netevia configurations. ## Required VAR-sheet fields | Field | What to send | | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | | `acquirer_bin` | Six-digit acquirer BIN | | `merchant_number` | Twelve-digit merchant number | | `store_number` | Four-digit store number assigned in the [VAR sheet](/docs/appendix/glossary#var-sheet "Processor-provided credential packet required to board a terminal.") | | `terminal_number` | Four-digit terminal number assigned in the VAR sheet | | `merchant_category_code` | Four-digit merchant category code | | `merchant_name` | Merchant business name | | `merchant_location` | Merchant city | | `merchant_state` | Two-letter US state code | | `city_code` | Merchant ZIP code | | `acceptor_street_address` | Merchant street address | | `industry_code` | Industry code assigned for the merchant, such as `R` for retail | | `acceptor_phone` | Merchant phone number | Use the assigned store and terminal numbers, including leading zeros. `0001` is an example and should be used only when it is the assigned value. API phone inputs accept formatted US numbers, including a leading `+1`, and normalize to ten digits. For example, `+1 (212) 555-0100` becomes `2125550100`. ## Optional VAR-sheet fields | Field | Behavior | | --------------------------------- | ---------------------------------------------------------------------------------------------- | | `acceptor_customer_service_phone` | Defaults to `acceptor_phone`; uses the same phone normalization | | `country_code` | Defaults to `USA`; `US`, `840`, and null also become `USA` | | `currency_code` | Defaults to `840` for US dollars | | `language_indicator` | Defaults to `00` | | `time_zone_diff` | Three-digit TSYS time-zone value; defaults to `705`. Use the assigned value for your merchant. | | `authentication_code` | Terminal authentication code, when needed to obtain a new key | | `gen_key` | Previously issued terminal key. Send either this or `authentication_code`, not both. | Snake\_case and camelCase VAR-sheet names are accepted. `merchant_location` means city, and `city_code` means ZIP. See [shared address rules](/docs/boarding-a-merchant/contracts#addresses). ## Request examples Send these request bodies to `POST /v2/terminals` with your Koard API key in the `X-Koard-apikey` header. Processor configuration IDs are listed by environment above. Merchant account IDs, credentials and merchant details below are fictional placeholders; replace them with your merchant's values. Preserve leading zeros. ### TSYS (no PIN) UAT example. Use your assigned VAR-sheet values. Store and terminal numbers must retain their leading zeros; the values below are examples, not defaults. ```json { "account_id": "00000000-0000-4000-8000-000000000001", "processor_config_id": "dcc6d302-2406-4973-b555-d070e66e6adb", "name": "Example Coffee", "var_sheet": { "acquirer_bin": "123456", "merchant_number": "123456789012", "store_number": "0001", "terminal_number": "0002", "merchant_category_code": "5812", "merchant_name": "Example Coffee", "merchant_location": "New York", "merchant_state": "NY", "city_code": "10016", "acceptor_street_address": "123 Main Street", "industry_code": "R", "acceptor_phone": "2125550100", "acceptor_customer_service_phone": "2125550101", "currency_code": "840", "country_code": "USA", "language_indicator": "00", "time_zone_diff": "705" } } ``` ### TSYS with PIN UAT example. Use your assigned VAR-sheet values. Store and terminal numbers must retain their leading zeros; the values below are examples, not defaults. ```json { "account_id": "00000000-0000-4000-8000-000000000001", "processor_config_id": "1f402cde-38c2-43fa-a665-c7a0440f5051", "name": "Example Coffee", "var_sheet": { "acquirer_bin": "123456", "merchant_number": "123456789012", "store_number": "0001", "terminal_number": "0002", "merchant_category_code": "5812", "merchant_name": "Example Coffee", "merchant_location": "New York", "merchant_state": "NY", "city_code": "10016", "acceptor_street_address": "123 Main Street", "industry_code": "R", "acceptor_phone": "2125550100", "acceptor_customer_service_phone": "2125550101", "currency_code": "840", "country_code": "USA", "language_indicator": "00", "time_zone_diff": "705" } } ``` ### Netevia UAT example. Use your assigned VAR-sheet values. Store and terminal numbers must retain their leading zeros; the values below are examples, not defaults. ```json { "account_id": "00000000-0000-4000-8000-000000000001", "processor_config_id": "8363f0d7-10b0-4b90-add1-5b9ed6b01e5d", "name": "Example Coffee", "var_sheet": { "acquirer_bin": "123456", "merchant_number": "123456789012", "store_number": "0001", "terminal_number": "0002", "merchant_category_code": "5812", "merchant_name": "Example Coffee", "merchant_location": "New York", "merchant_state": "NY", "city_code": "10016", "acceptor_street_address": "123 Main Street", "industry_code": "R", "acceptor_phone": "2125550100", "acceptor_customer_service_phone": "2125550101", "currency_code": "840", "country_code": "USA", "language_indicator": "00", "time_zone_diff": "705" } } ``` ## Update a terminal Use `PUT /v2/terminals/{terminal_id}` with only the fields you want to change: ```json { "var_sheet": { "merchant_name": "Example Coffee Downtown", "acceptor_street_address": "456 Main Street" } } ``` Omitted address fields keep their current values. See [automated batch scheduling](/docs/batch-and-settlements/automated-batch-scheduling) to configure an automatic batch schedule. # Fiserv terminal boarding ## Processor configurations Boarding requires a `processor_config_id` available to your account in the same environment. [List available configurations](/docs/boarding-a-merchant/processor-configurations) before choosing an ID. | Configuration | UAT `processor_config_id` | Production `processor_config_id` | | ------------------------------------- | ------------------------------------------------------------------------------------- | -------------------------------------- | | Fiserv RapidConnect | `6fb2f3e1-cd5c-4206-8edc-ff878cff572c` | Not listed | | Fiserv Nashville Global | `18083156-5ce9-4147-bf7c-3f792d010dc7` Fiserv: `1266dd34-e37e-43d0-ad54-9211fbad4c82` | `212ef266-1f7d-4dd3-a089-ee608473877c` | | Nashville North with merchant address | Not listed | Not listed | For production access to Fiserv RapidConnect, contact . If an ID is not listed, contact for the appropriate configuration. Examples with listed IDs use UAT. For production, use the production API URL, API key and configuration ID. Examples without a listed UAT ID are request templates. Replace `YOUR_PROCESSOR_CONFIG_ID` with an available configuration ID before submitting a request. Select the Fiserv configuration assigned to your merchant from `GET /v1/processor-configs`. Create the terminal with `POST /v2/terminals` using `account_id`, `processor_config_id`, `mid`, `tid`, and `mcc`. Fiserv configurations can require different VAR-sheet fields. Use the request format assigned to your configuration and the identifiers from your merchant's [VAR sheet](/docs/appendix/glossary#var-sheet "Processor-provided credential packet required to board a terminal."). | Request format | VAR-sheet details | | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | RapidConnect | Industry, merchant address, and assigned Group ID and [Datawire](/docs/appendix/glossary#datawire "Transport/ID used for some Fiserv terminals when Datawire is enabled.") ID when applicable | | Nashville Classic | Industry, merchant address and assigned equipment | | Nashville North | Industry, merchant address, assigned equipment and a required twelve-digit `settlement_mid` | | Cardnet North | Industry, merchant address and assigned equipment; optional `settlement_mid` | | Omaha | Industry, merchant address and assigned equipment | | Nashville Global | Seven-digit `mid`, required twelve-digit `var_sheet.merchant_number`, business name in `name`, and merchant address | | Nashville North with merchant address | Seven-digit `mid`, required twelve-digit `var_sheet.merchant_number`, business name in `name`, and merchant address | Use the examples below for RapidConnect and address-based Nashville configurations. The [Nashville Classic](/docs/boarding-a-merchant/fiserv/fiserv-nashville), [Nashville North](/docs/boarding-a-merchant/fiserv/fiserv-nashville-north), [Cardnet North](/docs/boarding-a-merchant/fiserv/fiserv-cardnet-north), and [Omaha](/docs/boarding-a-merchant/fiserv/fiserv-omaha) pages include their own request examples. If your assigned configuration's request format is unclear, confirm it with Koard before submitting the request. ## Merchant address Supply `merchant_street_address`, `merchant_city`, `merchant_state`, and `merchant_postal_code` in `var_sheet`. `merchant_street_address2` is optional. Country defaults to `USA`. `US`, `840`, and null also become `USA`. See [shared address rules](/docs/boarding-a-merchant/contracts#addresses). ## Assigned identifiers Use the [MID](/docs/appendix/glossary#mid "Processor-assigned merchant identifier used for routing and batching."), [TID](/docs/appendix/glossary#tid "Processor-assigned per-terminal identifier; batches are typically unique per MID/TID."), Group ID and Datawire ID assigned together for the merchant. Where your configuration uses Datawire, supplying `did` enables it. Setting `datawire_enabled` to true without a `did` requests a new Datawire ID. For Nashville North's equipment-based request, supply the twelve-digit `settlement_mid` separately from the seven-digit `mid`. For Nashville Global and Nashville North with merchant address, both the seven-digit `mid` and twelve-digit `var_sheet.merchant_number` are required. Send them as separate strings; one cannot replace the other. Keep the assigned `tid` unchanged, including leading zeros. Missing or malformed identifiers are rejected before boarding. ## Request examples Send these request bodies to `POST /v2/terminals` with your Koard API key in the `X-Koard-apikey` header. Processor configuration IDs are listed by environment above. Merchant account IDs, credentials and merchant details below are fictional placeholders; replace them with your merchant's values. Preserve leading zeros. ### Fiserv RapidConnect UAT example. Use the merchant, terminal, group and Datawire identifiers assigned together for this configuration. ```json { "account_id": "00000000-0000-4000-8000-000000000001", "processor_config_id": "6fb2f3e1-cd5c-4206-8edc-ff878cff572c", "name": "Example Coffee", "mid": "YOUR_ASSIGNED_MID", "tid": "00000001", "mcc": "5812", "var_sheet": { "merchant_street_address": "123 Main Street", "merchant_street_address2": "Suite 200", "merchant_city": "New York", "merchant_state": "NY", "merchant_postal_code": "10016", "country_code": "USA", "industry": "retail_qsr_grocery", "group_id": "40001", "did": "YOUR_ASSIGNED_DID", "datawire_enabled": true } } ``` ### Nashville Global UAT example. Use this when your assigned configuration requires a business name and merchant address. Send both the seven-digit mid and twelve-digit var\_sheet.merchant\_number, plus name, tid, mcc and the address fields shown. ```json { "account_id": "00000000-0000-4000-8000-000000000001", "processor_config_id": "18083156-5ce9-4147-bf7c-3f792d010dc7", "name": "Example Coffee", "mid": "1234567", "tid": "7654321", "mcc": "5812", "var_sheet": { "merchant_street_address": "123 Main Street", "merchant_street_address2": "Suite 200", "merchant_city": "New York", "merchant_state": "NY", "merchant_postal_code": "10016", "country_code": "USA", "merchant_number": "000123456789" } } ``` ### Nashville North with merchant address Request template: replace YOUR\_PROCESSOR\_CONFIG\_ID with an available configuration ID before submitting. Use this when your assigned configuration requires a business name and merchant address. Send both the seven-digit mid and twelve-digit var\_sheet.merchant\_number, plus name, tid, mcc and the address fields shown. ```json { "account_id": "00000000-0000-4000-8000-000000000001", "processor_config_id": "YOUR_PROCESSOR_CONFIG_ID", "name": "Example Coffee", "mid": "1234567", "tid": "7654321", "mcc": "5812", "var_sheet": { "merchant_street_address": "123 Main Street", "merchant_street_address2": "Suite 200", "merchant_city": "New York", "merchant_state": "NY", "merchant_postal_code": "10016", "country_code": "USA", "merchant_number": "000123456789" } } ``` ## Updates Use `PUT /v2/terminals/{terminal_id}` with only the fields you want to change. Omitted address fields retain their existing values. ## Batches and settlement For the supported managed Fiserv integration, Koard tracks batches automatically. Manual batch open, close and edit are unavailable. Monitor `batch.submitted` and subsequent acceptance or rejection webhooks, and retrieve batch details and reports through the read APIs. See [Fiserv Automatic Batches and Settlement](/docs/batch-and-settlements/fiserv-settlement). # Fiserv Nashville Classic ## Processor configurations Boarding requires a `processor_config_id` available to your account in the same environment. [List available configurations](/docs/boarding-a-merchant/processor-configurations) before choosing an ID. | Configuration | UAT `processor_config_id` | Production `processor_config_id` | |---|---|---| | Fiserv Nashville Classic | Not listed | Not listed | If an ID is not listed, contact [support@koard.com](mailto:support@koard.com) for the appropriate configuration. Examples without a listed UAT ID are request templates. Replace `YOUR_PROCESSOR_CONFIG_ID` with an available configuration ID before submitting a request. Use this format for a Nashville Classic configuration assigned to your merchant. See the [Fiserv overview](/docs/boarding-a-merchant/fiserv/fiserv-overview) for common fields. ## Request fields | Field | What to send | |---|---| | `account_id` | Merchant's Koard account ID | | `processor_config_id` | Assigned Nashville Classic configuration ID | | `mid` | Seven digits from the VAR sheet | | `tid` | Seven digits from the VAR sheet | | `mcc` | Four-digit merchant category code | | `var_sheet.group_id` | Assigned Group ID | | `var_sheet.industry` | Assigned industry value | | `var_sheet.equipment` | `CreditCallHCRC` or `CreditCallHCECRC`, as assigned | This format does not use `settlement_mid`. Supply the merchant street address, city, state and ZIP in `var_sheet`. Country defaults to `USA`; `US`, `840` and null also become `USA`. ## Request examples Send these request bodies to `POST /v2/terminals` with your Koard API key in the `X-Koard-apikey` header. Processor configuration IDs are listed by environment above. Merchant account IDs, credentials and merchant details below are fictional placeholders; replace them with your merchant's values. Preserve leading zeros. ### Fiserv Nashville Classic Request template: replace YOUR_PROCESSOR_CONFIG_ID with an available configuration ID before submitting. Use the VAR-sheet format assigned to your processor configuration. Replace all sample identifiers with your assigned values. ```json { "account_id": "00000000-0000-4000-8000-000000000001", "processor_config_id": "YOUR_PROCESSOR_CONFIG_ID", "name": "Example Coffee", "mid": "1234567", "tid": "7654321", "mcc": "5812", "var_sheet": { "merchant_street_address": "123 Main Street", "merchant_street_address2": "Suite 200", "merchant_city": "New York", "merchant_state": "NY", "merchant_postal_code": "10016", "country_code": "USA", "industry": "retail_qsr_grocery", "group_id": "10001", "equipment": "CreditCallHCRC" } } ``` ## Updates Use `PUT /v2/terminals/{terminal_id}` and send only changed fields. Omitted address fields retain their existing values. # Fiserv Nashville North ## Processor configurations Boarding requires a `processor_config_id` available to your account in the same environment. [List available configurations](/docs/boarding-a-merchant/processor-configurations) before choosing an ID. | Configuration | UAT `processor_config_id` | Production `processor_config_id` | |---|---|---| | Fiserv Nashville North (equipment-based) | Not listed | Not listed | If an ID is not listed, contact [support@koard.com](mailto:support@koard.com) for the appropriate configuration. Examples without a listed UAT ID are request templates. Replace `YOUR_PROCESSOR_CONFIG_ID` with an available configuration ID before submitting a request. Use this format for a Nashville North configuration assigned to your merchant. See the [Fiserv overview](/docs/boarding-a-merchant/fiserv/fiserv-overview) for common fields. This page covers the equipment-based request format. For a configuration that requires the business name and merchant address without equipment fields, use the [address-based Nashville North example](/docs/boarding-a-merchant/fiserv/fiserv-overview#nashville-north-with-merchant-address). ## Request fields | Field | What to send | |---|---| | `account_id` | Merchant's Koard account ID | | `processor_config_id` | Assigned Nashville North configuration ID | | `mid` | Seven digits from the VAR sheet | | `tid` | Seven digits from the VAR sheet | | `mcc` | Four-digit merchant category code | | `var_sheet.group_id` | Assigned Group ID | | `var_sheet.industry` | Assigned industry value | | `var_sheet.equipment` | `CreditCallLTDGTWRC` or `CRDCallResellerRCSS`, as assigned | Supply the assigned twelve-digit `var_sheet.settlement_mid`. It is required and is separate from the seven-digit `mid`. Supply the merchant street address, city, state and ZIP in `var_sheet`. Country defaults to `USA`; `US`, `840` and null also become `USA`. ## Request examples Send these request bodies to `POST /v2/terminals` with your Koard API key in the `X-Koard-apikey` header. Processor configuration IDs are listed by environment above. Merchant account IDs, credentials and merchant details below are fictional placeholders; replace them with your merchant's values. Preserve leading zeros. ### Fiserv Nashville North Request template: replace YOUR_PROCESSOR_CONFIG_ID with an available configuration ID before submitting. Use the VAR-sheet format assigned to your processor configuration. Replace all sample identifiers with your assigned values. ```json { "account_id": "00000000-0000-4000-8000-000000000001", "processor_config_id": "YOUR_PROCESSOR_CONFIG_ID", "name": "Example Coffee", "mid": "1234567", "tid": "7654321", "mcc": "5812", "var_sheet": { "merchant_street_address": "123 Main Street", "merchant_street_address2": "Suite 200", "merchant_city": "New York", "merchant_state": "NY", "merchant_postal_code": "10016", "country_code": "USA", "industry": "retail_qsr_grocery", "group_id": "10001", "equipment": "CreditCallLTDGTWRC", "settlement_mid": "123456789012" } } ``` ## Updates Use `PUT /v2/terminals/{terminal_id}` and send only changed fields. Omitted address fields retain their existing values. # Fiserv Cardnet North ## Processor configurations Boarding requires a `processor_config_id` available to your account in the same environment. [List available configurations](/docs/boarding-a-merchant/processor-configurations) before choosing an ID. | Configuration | UAT `processor_config_id` | Production `processor_config_id` | | -------------------- | ------------------------- | -------------------------------- | | Fiserv Cardnet North | Not listed | Not listed | For production access to Fiserv Cardnet North, contact . If an ID is not listed, contact for the appropriate configuration. Examples without a listed UAT ID are request templates. Replace `YOUR_PROCESSOR_CONFIG_ID` with an available configuration ID before submitting a request. Use this format for a Cardnet North configuration assigned to your merchant. See the [Fiserv overview](/docs/boarding-a-merchant/fiserv/fiserv-overview) for common fields. ## Request fields | Field | What to send | | --------------------- | ---------------------------------------------------------- | | `account_id` | Merchant's Koard account ID | | `processor_config_id` | Assigned Cardnet North configuration ID | | `mid` | Twelve digits from the VAR sheet | | `tid` | Six alphanumeric characters from the VAR sheet | | `mcc` | Four-digit merchant category code | | `var_sheet.group_id` | Assigned Group ID | | `var_sheet.industry` | Assigned industry value | | `var_sheet.equipment` | `CreditCallLTDGTWRC` or `CRDCallResellerRCSS`, as assigned | `var_sheet.settlement_mid` is optional and defaults to `mid`. Supply it when a different settlement [MID](/docs/appendix/glossary#mid "Processor-assigned merchant identifier used for routing and batching.") is assigned. Supply the merchant street address, city, state and ZIP in `var_sheet`. Country defaults to `USA`; `US`, `840` and null also become `USA`. ## Request examples Send these request bodies to `POST /v2/terminals` with your Koard API key in the `X-Koard-apikey` header. Processor configuration IDs are listed by environment above. Merchant account IDs, credentials and merchant details below are fictional placeholders; replace them with your merchant's values. Preserve leading zeros. ### Fiserv Cardnet North Request template: replace YOUR\_PROCESSOR\_CONFIG\_ID with an available configuration ID before submitting. Use the VAR-sheet format assigned to your processor configuration. Replace all sample identifiers with your assigned values. ```json { "account_id": "00000000-0000-4000-8000-000000000001", "processor_config_id": "YOUR_PROCESSOR_CONFIG_ID", "name": "Example Coffee", "mid": "123456789012", "tid": "A1B2C3", "mcc": "5812", "var_sheet": { "merchant_street_address": "123 Main Street", "merchant_street_address2": "Suite 200", "merchant_city": "New York", "merchant_state": "NY", "merchant_postal_code": "10016", "country_code": "USA", "industry": "retail_qsr_grocery", "group_id": "30001", "equipment": "CRDCallResellerRCSS", "settlement_mid": "123456789012" } } ``` ## Updates Use `PUT /v2/terminals/{terminal_id}` and send only changed fields. Omitted address fields retain their existing values. # Fiserv Omaha ## Processor configurations Boarding requires a `processor_config_id` available to your account in the same environment. [List available configurations](/docs/boarding-a-merchant/processor-configurations) before choosing an ID. | Configuration | UAT `processor_config_id` | Production `processor_config_id` | |---|---|---| | Fiserv Omaha | Not listed | Not listed | For production access to Fiserv Omaha, contact [support@koard.com](mailto:support@koard.com). If an ID is not listed, contact [support@koard.com](mailto:support@koard.com) for the appropriate configuration. Examples without a listed UAT ID are request templates. Replace `YOUR_PROCESSOR_CONFIG_ID` with an available configuration ID before submitting a request. Use this format for an Omaha configuration assigned to your merchant. See the [Fiserv overview](/docs/boarding-a-merchant/fiserv/fiserv-overview) for common fields. ## Request fields | Field | What to send | |---|---| | `account_id` | Merchant's Koard account ID | | `processor_config_id` | Assigned Omaha configuration ID | | `mid` | Seven digits from the VAR sheet | | `tid` | Seven digits from the VAR sheet | | `mcc` | Four-digit merchant category code | | `var_sheet.group_id` | Assigned Group ID | | `var_sheet.industry` | Assigned industry value | | `var_sheet.equipment` | `CREDITCALLHCRC`, as assigned | This format does not use `settlement_mid`. Supply the merchant street address, city, state and ZIP in `var_sheet`. Country defaults to `USA`; `US`, `840` and null also become `USA`. ## Request examples Send these request bodies to `POST /v2/terminals` with your Koard API key in the `X-Koard-apikey` header. Processor configuration IDs are listed by environment above. Merchant account IDs, credentials and merchant details below are fictional placeholders; replace them with your merchant's values. Preserve leading zeros. ### Fiserv Omaha Request template: replace YOUR_PROCESSOR_CONFIG_ID with an available configuration ID before submitting. Use the VAR-sheet format assigned to your processor configuration. Replace all sample identifiers with your assigned values. ```json { "account_id": "00000000-0000-4000-8000-000000000001", "processor_config_id": "YOUR_PROCESSOR_CONFIG_ID", "name": "Example Coffee", "mid": "1234567", "tid": "7654321", "mcc": "5812", "var_sheet": { "merchant_street_address": "123 Main Street", "merchant_street_address2": "Suite 200", "merchant_city": "New York", "merchant_state": "NY", "merchant_postal_code": "10016", "country_code": "USA", "industry": "retail_qsr_grocery", "group_id": "40001", "equipment": "CREDITCALLHCRC" } } ``` ## Updates Use `PUT /v2/terminals/{terminal_id}` and send only changed fields. Omitted address fields retain their existing values. # Elavon terminal boarding ## Processor configurations Boarding requires a `processor_config_id` available to your account in the same environment. [List available configurations](/docs/boarding-a-merchant/processor-configurations) before choosing an ID. | Configuration | UAT `processor_config_id` | Production `processor_config_id` | | ------------- | -------------------------------------- | -------------------------------------- | | Elavon | `4e4e07cb-a017-41ec-b718-c7c072a5b4a2` | `33dfde06-4c35-4416-a6ee-28ba31d434da` | For production access to Elavon, contact . Examples with listed IDs use UAT. For production, use the production API URL, API key and configuration ID. Select the Elavon configuration assigned to your merchant from `GET /v1/processor-configs`. Create the terminal with `POST /v2/terminals`. | Field | What to send | | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | | `account_id` | Merchant's Koard account ID | | `processor_config_id` | Elavon configuration ID | | `name` | Optional terminal display name | | `mcc` | Four-digit merchant category code | | `tid` | Full 22-digit terminal identifier, if supplying it directly | | `var_sheet.bank_number` | Six-digit bank number, when supplying the identifier in parts | | `var_sheet.terminal_number` | Assigned terminal number of 1–16 digits, when supplying the identifier in parts; shorter values are left-padded with zeros to 16 digits | | `var_sheet` | Required object; use `{}` when supplying the full `tid` without other details | Supply the full `tid`, or supply both bank and terminal numbers. Use the assigned terminal number from your [VAR sheet](/docs/appendix/glossary#var-sheet "Processor-provided credential packet required to board a terminal."), including leading zeros. A lane number alone does not replace the terminal number. `mid` is optional. ## Request examples Send these request bodies to `POST /v2/terminals` with your Koard API key in the `X-Koard-apikey` header. Processor configuration IDs are listed by environment above. Merchant account IDs, credentials and merchant details below are fictional placeholders; replace them with your merchant's values. Preserve leading zeros. ### Elavon UAT example. Supply the six-digit bank number and the assigned terminal number from your VAR sheet. Terminal numbers accept 1–16 digits; shorter values are left-padded with zeros to 16 digits. You can instead supply the full 22-digit tid with an empty var\_sheet object. ```json { "account_id": "00000000-0000-4000-8000-000000000001", "processor_config_id": "4e4e07cb-a017-41ec-b718-c7c072a5b4a2", "name": "Example Coffee", "mcc": "5812", "var_sheet": { "bank_number": "123456", "terminal_number": "0000000012345678", "country_code": "USA", "currency_code": "840" } } ``` ## Optional merchant details The VAR sheet accepts `merchant_name`, `merchant_street_address`, `merchant_street_address2`, `merchant_city`, `merchant_state`, and `merchant_postal_code`. Use `country_code: "USA"` and `currency_code: "840"` for US dollar processing. `US`, `840`, and `0840` are also accepted as US country values. An omitted or null country defaults to `USA` on create. `acceptor_phone` and `acceptor_customer_service_phone` accept formatted US numbers, including a leading `+1`, and normalize to ten digits. ## Updates Use `PUT /v2/terminals/{terminal_id}` with only the changed fields. An omitted country keeps its current value; a null country becomes `USA`. See [shared address rules](/docs/boarding-a-merchant/contracts#addresses). # Worldpay terminal boarding ## Processor configurations Boarding requires a `processor_config_id` available to your account in the same environment. [List available configurations](/docs/boarding-a-merchant/processor-configurations) before choosing an ID. | Configuration | UAT `processor_config_id` | Production `processor_config_id` | |---|---|---| | Worldpay | `02eb8a39-b992-423b-ba41-2a0e28958519` | Not listed | For production access to Worldpay, contact [support@koard.com](mailto:support@koard.com). If an ID is not listed, contact [support@koard.com](mailto:support@koard.com) for the appropriate configuration. Examples with listed IDs use UAT. For production, use the production API URL, API key and configuration ID. Select the Worldpay configuration assigned to your merchant from `GET /v1/processor-configs`. Create the terminal with `POST /v2/terminals`. | Field | What to send | |---|---| | `account_id` | Merchant's Koard account ID | | `processor_config_id` | Worldpay configuration ID | | `mid` | Twelve-digit merchant ID | | `tid` | Three-digit terminal ID | | `mcc` | Four-digit merchant category code | | `var_sheet.bank_id` | Four-digit bank ID; required | | `var_sheet.lane_number` | Optional lane number; defaults to `001` | | `name` | Optional terminal display name | Keep identifiers as strings and preserve leading zeros. ## Request examples Send these request bodies to `POST /v2/terminals` with your Koard API key in the `X-Koard-apikey` header. Processor configuration IDs are listed by environment above. Merchant account IDs, credentials and merchant details below are fictional placeholders; replace them with your merchant's values. Preserve leading zeros. ### Worldpay UAT example. Supply the twelve-digit mid, three-digit tid and four-digit bank_id assigned to your merchant. ```json { "account_id": "00000000-0000-4000-8000-000000000001", "processor_config_id": "02eb8a39-b992-423b-ba41-2a0e28958519", "name": "Example Coffee", "mid": "123456789012", "tid": "001", "mcc": "5812", "var_sheet": { "bank_id": "1234", "lane_number": "001", "country_code": "USA" } } ``` ## Address and updates Optional VAR-sheet fields include `merchant_name`, `merchant_street_address`, `merchant_street_address2`, `merchant_city`, `merchant_state`, `merchant_postal_code`, and `country_code`. Country defaults to `USA` on create. `US` and `840` are also accepted as US country values. Use `PUT /v2/terminals/{terminal_id}` with only the changed fields. Omit country to keep its current value, or send null to set it to `USA`. See [shared address rules](/docs/boarding-a-merchant/contracts#addresses) and [automated batch scheduling](/docs/batch-and-settlements/automated-batch-scheduling). # Payroc terminal boarding ## Processor configurations Boarding requires a `processor_config_id` available to your account in the same environment. [List available configurations](/docs/boarding-a-merchant/processor-configurations) before choosing an ID. | Configuration | UAT `processor_config_id` | Production `processor_config_id` | | ------------- | -------------------------------------- | -------------------------------------- | | Payroc | `5c522102-dcc5-45b8-a57c-95792ea1ae9c` | `e3cc6c01-5532-48f7-8cd3-639e3c3f10b0` | For production access to Payroc, contact . Examples with listed IDs use UAT. For production, use the production API URL, API key and configuration ID. Use the **Processing [MID](/docs/appendix/glossary#mid "Processor-assigned merchant identifier used for routing and batching.")** and **Processing Terminal ID** assigned by Payroc. Select your Payroc configuration from `GET /v1/processor-configs`. Create the terminal with `POST /v2/terminals`: | Field | What to send | | --------------------- | ------------------------------------------ | | `account_id` | Merchant's Koard account ID | | `processor_config_id` | Payroc configuration ID | | `name` | Optional terminal display name | | `description` | Optional description | | `mid` | Payroc Processing MID | | `tid` | Payroc Processing Terminal ID | | `mcc` | Optional four-digit merchant category code | Send `mid` and `tid` at the top level. Omit `var_sheet`; a non-empty VAR sheet is rejected. ## Request examples Send these request bodies to `POST /v2/terminals` with your Koard API key in the `X-Koard-apikey` header. Processor configuration IDs are listed by environment above. Merchant account IDs, credentials and merchant details below are fictional placeholders; replace them with your merchant's values. Preserve leading zeros. ### Payroc UAT example. Send the assigned merchant and terminal identifiers at the top level. No VAR sheet is needed. ```json { "account_id": "00000000-0000-4000-8000-000000000001", "processor_config_id": "5c522102-dcc5-45b8-a57c-95792ea1ae9c", "name": "Example Coffee", "mid": "1234567", "tid": "7654321", "mcc": "5812" } ``` ## Updates Use `PUT /v2/terminals/{terminal_id}` to update the terminal. # Creating a Sandbox Apple Account Use a dedicated test iPhone and a Sandbox Apple Account for supported Apple Pay and Tap to Pay sandbox testing. Merchant SDK login credentials and this Apple account serve different purposes. ## Create a Tester Create the sandbox tester in App Store Connect following [Apple's current instructions](https://developer.apple.com/help/app-store-connect/test-in-app-purchases/create-a-sandbox-apple-account/). Use a dedicated email and the region required by your test plan. Keep the account credentials secure. ## Sign In on the Test Device For Apple Pay sandbox testing, Apple instructs you to sign out of the device's Apple Account and sign in using the Sandbox Apple Account. Use a dedicated device so this does not disrupt a personal or production device. Follow [Apple Pay sandbox testing](https://developer.apple.com/apple-pay/sandbox-testing/) for wallet provisioning and supported test cards. The App Store or Developer settings used for **in-app purchase** sandbox testing are a separate workflow. Signing into a StoreKit sandbox account, resetting purchase history, or looking for purchase receipts does not configure a contactless acceptance test. ## Prepare the Koard Integration - Use the approved Tap to Pay entitlement, supported device, and SDK release. - Select Koard UAT and the processor configuration and test credentials assigned to your project. - Log in as the merchant and select the active location before linking and preparing the reader. - Use the processor/network test cards or sandbox wallet tokens required by the certification plan. Generic PAN/CVV numbers do not replace contactless reader data. ## Troubleshooting Check the device Apple account, entitlement, Developer Mode, provisioning profile, active merchant location, and SDK readiness. Confirm that wallet provisioning and acceptance are using compatible sandbox test credentials. Consult Apple or your certification contact for account restrictions rather than assuming StoreKit purchase reset procedures apply. ## Next Steps - [Install the SDK](installing-sdk.md). - [Run payments](running-payments.md). - [Prepare for production](getting-ready-for-production.md). # Setting Up the Entitlement for Tap to Pay on iPhone Learn how to request and configure the [Tap to Pay on iPhone](/docs/appendix/glossary#tap-to-pay "Apple contactless acceptance on iPhone without extra hardware.") entitlement from Apple, which is required to enable contactless payment processing in your iOS application. [Get started with Koard](/docs/introduction) ## Overview The Tap to Pay on iPhone entitlement is a managed capability that Apple provides to authorized [Payment Service Providers (PSPs)](/docs/appendix/glossary#psp "Company that serves multiple merchants with payment processing.") and their partners. This entitlement allows your app to use Apple's ProximityReader framework to accept contactless payments directly on iPhone. This guide covers the entitlement request process, configuration steps, and verification procedures based on [Apple's official documentation](https://developer.apple.com/documentation/proximityreader/setting-up-the-entitlement-for-tap-to-pay-on-iphone). **What you learn** In this guide, you'll learn: * How to request the [Tap to Pay entitlement](/docs/appendix/glossary#tap-to-pay-entitlement "Apple Managed Capability required before building Tap to Pay apps.") from Apple * How to configure the entitlement in your Apple Developer account * How to add the entitlement to your Xcode project * How to verify the entitlement is properly configured * How to handle entitlement requirements for development and distribution **Prerequisites** Before you begin, ensure you have: * **Apple Developer Account** (organization-level account required) * **Account Holder Access** (entitlement requests must be made by the account holder) * **PSP Partnership** (your organization must be an authorized Payment Service Provider or partner) * **Xcode 16.3 or later** (for project configuration) * **App ID Created** (your app identifier must be registered in Apple Developer) * **[Sandbox Apple Account](/docs/appendix/glossary#sandbox-apple-account "Dedicated Apple tester account on a test iPhone for certification and QA.") signed in on test device** (dedicated iPhone running Developer Mode for entitlement validation) **Keep a Test iPhone Ready**: Entitlement validation requires running builds on a physical test device signed in with your Sandbox Apple Account. Do not rely on production Apple IDs for these flows. ## Requesting the Entitlement The Tap to Pay on iPhone entitlement must be requested through Apple's developer portal. This process is handled by Apple and requires approval. **1. Access Apple Developer Portal** 1. **Log in to your Apple Developer account** as the account holder 2. **Navigate to Certificates, Identifiers & Profiles** 3. **Select your organization** if you have multiple accounts **Important**: Only the account holder can request the Tap to Pay entitlement. Team members or admins cannot submit this request. **2. Request Tap to Pay Entitlement** 1. **Go to Identifiers** section 2. **Select your App ID** (or create one if needed) 3. **Navigate to Additional Capabilities** 4. **Find "Tap to Pay on iPhone"** in the list of capabilities 5. **Click "Request"** or "Enable" to submit your request **3. Wait for Approval** Apple will review your request and typically respond within **one to two business days**. You'll receive an email notification when the entitlement is approved or if additional information is needed. **Processing Time**: The approval process typically takes 1-2 business days. You must start with the development certificate to access Apple's [CERT environment](/docs/appendix/glossary#apple-cert "Apple certification environment used when validating Tap to Pay flows."). **4. Verify Entitlement Status** Once approved: 1. **Return to Certificates, Identifiers & Profiles** 2. **Select your App ID** 3. **Check Additional Capabilities** 4. **Verify "Tap to Pay on iPhone"** appears under Managed Capabilities The entitlement will now be available for use in your provisioning profiles. ## Configure Your App ID After receiving approval, configure your App ID to include the entitlement: ### Enable the Capability 1. **Navigate to Certificates, Identifiers & Profiles > Identifiers** 2. **Select your App ID** 3. **Scroll to Additional Capabilities** 4. **Enable "Tap to Pay on iPhone"** 5. **Save your changes** The capability will now be available for your App ID and can be included in provisioning profiles. ## Add Entitlement to Your Xcode Project Once the entitlement is approved and configured in your Apple Developer account, add it to your Xcode project: **1. Create Entitlements File** 1. **Open your project in Xcode** 2. **Select your project** in the Project Navigator 3. **Choose File > New > File** 4. **Select Property List** under Resource 5. **Name the file** `[YourProjectName].entitlements` 6. **Add it to your app target** **2. Configure Build Settings** 1. **Select your project** in the Project Navigator 2. **Select your app target** 3. **Go to Build Settings tab** 4. **Search for "Code Signing Entitlements"** 5. **Set the path** to `[YourProjectName].entitlements` **3. Add Entitlement Key** 1. **Open the `.entitlements` file** in Xcode 2. **Add the following key-value pair**: ```xml com.apple.developer.proximity-reader.payment.acceptance ``` The file should look like this: ```xml com.apple.developer.proximity-reader.payment.acceptance ``` **4. Update Provisioning Profile** 1. **In Xcode, go to Signing & Capabilities** 2. **Select your development team** 3. **Xcode will automatically download** a new provisioning profile that includes the entitlement 4. **Verify the entitlement** appears in the Capabilities section **Note**: If Xcode doesn't automatically update the provisioning profile, you may need to manually regenerate it in the Apple Developer portal. ## Verify Entitlement Configuration After configuring the entitlement, verify it's properly set up: ### Check in Xcode 1. **Select your project** in Xcode 2. **Select your app target** 3. **Go to Signing & Capabilities tab** 4. **Verify "Tap to Pay on iPhone"** appears in the Capabilities section ### Verify in Code You can programmatically verify the entitlement is present by checking the `readerIdentifier` property: ```swift import ProximityReader do { let readerIdentifier = try await PaymentCardReader().readerIdentifier print("Entitlement verified: (readerIdentifier)") // Entitlement is present and valid } catch { print("Entitlement error: (error)") // Handle notAllowed error if entitlement is missing } ``` If the entitlement is missing, `readerIdentifier` will throw a `notAllowed` error. ## Development vs Distribution Entitlements The Tap to Pay entitlement has different requirements for development and distribution: ### Development Entitlement * **Purpose**: Internal testing and development * **Provisioning**: Development provisioning profiles * **Distribution**: Ad-hoc distribution via .ipa files * **Testing**: Limited to registered test devices ### Distribution Entitlement * **Purpose**: TestFlight and App Store distribution * **Provisioning**: Distribution provisioning profiles * **Distribution**: TestFlight beta testing and App Store submissions * **Testing**: Available to all TestFlight testers and App Store users **Important**: If you've already received the development entitlement and need to distribute via TestFlight or the App Store, you must request the distribution entitlement separately. Respond to the original approval email from Apple to request the distribution entitlement. ## Troubleshooting ### Entitlement Not Appearing If the entitlement doesn't appear in your Apple Developer account: * **Verify account holder status**: Only the account holder can request entitlements * **Check PSP partnership**: Ensure your organization is authorized as a PSP or partner * **Contact Apple**: Reach out to Apple Developer Support if the entitlement is not available ### Entitlement Not Working in Xcode If the entitlement doesn't work in Xcode: * **Verify provisioning profile**: Ensure your provisioning profile includes the entitlement * **Check entitlements file**: Verify the `.entitlements` file contains the correct key * **Update provisioning profile**: Regenerate your provisioning profile in Apple Developer portal * **Clean build folder**: In Xcode, go to Product > Clean Build Folder ### Reader Identifier Returns Error If `readerIdentifier` throws a `notAllowed` error: * **Verify entitlement in Apple Developer**: Check that the entitlement is approved and enabled * **Check provisioning profile**: Ensure the profile includes the Tap to Pay capability * **Verify device registration**: For development, ensure test devices are registered * **Check code signing**: Verify your app is signed with the correct provisioning profile ## Additional Resources * [Apple's Tap to Pay Documentation](https://developer.apple.com/documentation/proximityreader/setting-up-the-entitlement-for-tap-to-pay-on-iphone) * [ProximityReader Framework Reference](https://developer.apple.com/documentation/proximityreader) * [Apple Developer Portal](https://developer.apple.com/account) ## See also * [Adding Support for Tap to Pay on iPhone](/docs/setting-up-the-ios-sdk/adding-support-for-tap-to-pay-on-iphone) - Complete implementation guide * [Installing the SDK](/docs/setting-up-the-ios-sdk/installing-the-sdk) - Set up the Koard iOS SDK * [Creating a Sandbox Apple Account](/docs/setting-up-the-ios-sdk/creating-a-sandbox-apple-account) - Prepare dedicated testers * [Getting Ready for Production](/docs/setting-up-the-ios-sdk/getting-ready-for-production) - Configure schemes and [API keys](/docs/appendix/glossary#api-key "Secret header credential for REST calls; v5 keys use scoped permissions.") * [Developing with Apple](/docs/appendix/developing-with-apple) - Security and environment guidelines # Adding Support for Tap to Pay on iPhone Enable [Tap to Pay on iPhone](/docs/appendix/glossary#tap-to-pay "Apple contactless acceptance on iPhone without extra hardware.") to allow merchants to accept contactless payments directly on their iPhone without additional hardware. If you're ready to start developing, see our [iOS SDK installation guide](/docs/setting-up-the-ios-sdk/installing-the-sdk). [Get started with Koard](/docs/introduction) **What you learn** In this guide, you'll learn: * How to request the [Tap to Pay entitlement](/docs/appendix/glossary#tap-to-pay-entitlement "Apple Managed Capability required before building Tap to Pay apps.") from Apple * How to configure your Xcode project for Tap to Pay * How to set up testing in developer mode * How to distribute your app for testing and production **Prerequisites** Before you begin, ensure you have: * **iOS 17.4 or later** (minimum required version) * **iPhone XS or later** (supported hardware) * **Apple Developer Account** (organization-level account required) * **Koard iOS SDK** (installed in your project) * **Valid merchant account** (configured in [Koard MMS](/docs/appendix/glossary#mms "Koard portal for merchants, terminals, locations, credentials, and API keys.")) * **[Sandbox Apple Account](/docs/appendix/glossary#sandbox-apple-account "Dedicated Apple tester account on a test iPhone for certification and QA.") signed in on test device** (dedicated iPhone in Developer Mode) **Use a Dedicated Test iPhone**: Keep your Sandbox Apple Account signed in on a separate test device. Production Apple IDs cannot complete Sandbox Tap to Pay transactions. ## Enable the Tap to Pay Entitlement Enabling the Tap to Pay Entitlement is a critical step that is handled by Apple. Typically, you will need to request the entitlement from Apple through their developer portal. ### Requesting Tap to Pay Entitlement from Apple To enable Tap to Pay on iPhone, follow these steps: 1. **Log in to your Apple Developer account** as the account holder 2. **Navigate to Certificates, Identifiers & Profiles** 3. **Select Tap to Pay on iPhone Entitlement** and submit a request 4. **Wait for approval** - Apple will add the entitlement under Managed Capabilities **Processing Time**: The process to get approval from Apple typically takes **one or two business days**. You will need to start with the development certificate to get access to the [Apple CERT environment](/docs/appendix/glossary#apple-cert "Apple certification environment used when validating Tap to Pay flows."). ## Configure Your Xcode Project Once you have the entitlement, configure your Xcode project: ### 1. Enable Tap to Pay Capability 1. **Sign in to your Apple Developer Account** 2. **Create an App ID** (if you don't have one already) 3. **Go to Certificates, Identifiers & Profiles > Identifiers** 4. **Select your app and go to Additional Capabilities** 5. **Enable Tap to Pay on iPhone and save** ### 2. Create a Provisioning Profile 1. **Open Xcode and select your project** 2. **Navigate to Signing & Capabilities** 3. **Under Provisioning Profile, select Download Profile** 4. **Choose the new provisioning profile** ### 3. Add Entitlements File 1. **In Xcode, select your project in Project Navigator** 2. **Create a new Property List file** (File > New > File > Resource) 3. **Name the file `[ProjectName].entitlements`** 4. **Open Build Settings and locate Code Signing Entitlements** 5. **Set its value to the path of the .entitlements file** Open the `.entitlements` file and add the following key-value pair: ```xml com.apple.developer.proximity-reader.payment.acceptance ``` **Additional Resources**: More details on enabling the tap to pay entitlement can be found in [Apple's Developer Documentation](https://developer.apple.com/documentation/passkit/tap_to_pay_on_iphone). ## Testing in Developer Mode To test the iOS app in developer mode, follow these additional steps: ### Enable Developer Mode 1. **On the test iPhone, go to Settings > Privacy & Security** 2. **Enable Developer Mode** ### Use a Sandbox Apple Account The sandbox account must be: * **Freshly created in App Store Connect** * **Signed into iCloud** * **Linked to the test iPhone** **Important**: Existing accounts attempting to test an App with Tap to Pay in cert mode will be blocked from creating a card reader session due to Apple's security policies. ### Register a Test Device 1. **Retrieve the UDID of the test device** 2. **Add the UDID to Allowed Devices in the Apple Developer account** 3. **Update the Provisioning Profile to include the allowed devices** **Device Management**: Any new test device will need to have the UDID uploaded and a **NEW provisioning profile** will need to be added anytime new devices are added. The app will then need a new archive file that will be shared with the new device. ### Distribute via .ipa File 1. **Create an .ipa build file in Xcode** 2. **Share the build with your internal team for testing** **Testing Recommendation**: It is highly recommended that you have a **secondary iPhone or higher** to test transactions. Otherwise, developer mode means that engineers will have to sign into a test account onto their own devices to test the mPOS app with Tap to Pay. This is a hard limitation set by Apple and has no workaround at the moment. ### TestFlight and App Store Distribution **TestFlight beta testing** and **App Store submissions** require a separate entitlement that allows distribution. If you've already completed your testing with the non-distribution entitlement, respond to the original email and re-request the Tap to Pay on iPhone Entitlement. ## Testing Environment Running a transaction in the cert environment will route payments to all the processor and card brands test environment. Transactions sent through this setup will **NOT authorize a real transaction** but will be production-like in terms of workflow. ### Test Cards Use the contactless test cards or sandbox wallet tokens assigned by your processor or network for your environment and certification plan. A test tap must produce actual reader-generated EMV data; a keyed PAN, expiry, and CVV do not simulate a COTS contactless transaction. Confirm the supported brands, wallet tokens, PIN/CVM scenarios, and expected outcomes with your certification contact. Follow the [test-card guidance](/docs/appendix/resources#test-cards); do not substitute another processor's fixtures. ## Production Deployment Before going live: 1. **Complete merchant verification** in Koard MMS 2. **Switch to production environment** in your app configuration 3. **Test with real payment methods** (in a controlled environment) 4. **Submit for App Store review** with the distribution entitlement 5. **Review scheme setup** in [Getting Ready for Production](/docs/setting-up-the-ios-sdk/getting-ready-for-production) to ensure the correct [API keys](/docs/appendix/glossary#api-key "Secret header credential for REST calls; v5 keys use scoped permissions.") ship with your archive. ## Card Reader Lifecycle in the SDK Once the entitlement and provisioning are in place, the SDK manages the Apple ProximityReader card reader. After authenticating the merchant, link the account and prepare the reader before taking payments. ```swift import KoardSDK // Call after merchant login and active-location selection. try await KoardMerchantSDK.shared.prepare() // On accountNotLinked, show a linking action. That action should run: // try await KoardMerchantSDK.shared.linkAccountAsync() // try await KoardMerchantSDK.shared.prepare() ``` ### Monitor Reader Status The SDK owns the reader event subscription. Use `KoardMerchantSDK.shared.status` for readiness and its `info` message; do not attach another `readerEvents` iterator. See the cancelable SwiftUI polling example in [Running Payments](/docs/setting-up-the-ios-sdk/running-payments#session-management). ```swift let status = KoardMerchantSDK.shared.status let paymentEnabled = status.isReady let statusMessage = status.info ``` ### Present the Tap to Pay Tutorial On iOS 18 and later, you can present Apple's built-in "How to Tap" tutorial from a visible view controller: ```swift if #available(iOS 18.0, *) { try KoardMerchantSDK.shared.presentTutorial(from: viewController) } ``` **Reader operations are serialized**: The SDK serializes ProximityReader operations (prepare, linkAccount, isAccountLinked, and reads), so a sale started while `prepare()` is still running waits for readiness instead of failing with a "reader busy" error. If you switch the active location, the reader re-prepares for the new location before the next charge. **Handle cancellation**: When the customer cancels at the Apple Tap to Pay sheet, the sale, pre-auth, and card-present refund flows throw `KoardMerchantSDKError.TTPPaymentFailed(.canceled)`. Treat this as a benign "canceled" outcome rather than a failure. ## Troubleshooting ### Common Issues * **Entitlement Not Found**: Ensure you've requested and received approval from Apple * **Provisioning Profile Issues**: Make sure your provisioning profile includes the Tap to Pay capability * **Device Registration**: Verify test devices are properly registered in your Apple Developer account * **Sandbox Account Issues**: Use a fresh Apple ID created specifically for testing ### Support For technical issues with Tap to Pay integration: * Check the [Apple Best Practices](/docs/appendix/apple-best-practices-and-guidelines) guide * Review [Payment Configurations](/docs/appendix/payment-configurations) for advanced settings * Contact Koard support for SDK-specific issues ## See also This wraps up the Tap to Pay setup. See the links below for next steps in your integration: * [Creating a Sandbox Apple Account](/docs/setting-up-the-ios-sdk/creating-a-sandbox-apple-account) - Set up test identities * [Installing the SDK](/docs/setting-up-the-ios-sdk/installing-the-sdk) - Complete SDK integration guide * [Running Payments](/docs/setting-up-the-ios-sdk/running-payments) - Implement payment processing * [Payment Lifecycle](/docs/payments/payment-lifecycle) - Understand the complete payment flow * [Getting Ready for Production](/docs/setting-up-the-ios-sdk/getting-ready-for-production) - Configure schemes for launch * [Apple Best Practices](/docs/appendix/apple-best-practices-and-guidelines) - Follow Apple's guidelines # Installing the SDK Install the Koard Merchant SDK to enable tap-to-pay functionality in your iOS application. If you're ready to start developing, see our [Tap to Pay configuration guide](/docs/setting-up-the-ios-sdk/adding-support-for-tap-to-pay-on-iphone). [Get started with Koard](/docs/introduction) **What you learn** In this guide, you'll learn: * How to add the Koard SDK with Swift Package Manager * How to install the SDK manually as an XCFramework * How to configure embed settings for proper functionality * How to verify your installation is working correctly **Prerequisites** Before you begin, ensure you have: * **Xcode 16.3 or later** (required for building and distribution) * **iOS 17.4+ deployment target** (minimum supported version) * **Swift 5.9+** (minimum Swift toolchain) * **iPhone XS or greater** (supported hardware for tap-to-pay) * **Valid Koard merchant account** (configured in [Koard MMS](/docs/appendix/glossary#mms "Koard portal for merchants, terminals, locations, credentials, and API keys.")) * **[Sandbox Apple Account](/docs/appendix/glossary#sandbox-apple-account "Dedicated Apple tester account on a test iPhone for certification and QA.") signed in on test device** (dedicated iPhone with Developer Mode enabled) **Current version**: The latest published release is **1.0.21**, adding optional transaction metadata, `deinit()`, and `isInitialized`. See [Transaction Metadata](/docs/setting-up-the-ios-sdk/transaction-metadata) and [SDK Lifecycle and Deinitialization](/docs/setting-up-the-ios-sdk/sdk-lifecycle). See the SDK [CHANGELOG](https://github.com/koardlabs/koard-ios/blob/main/CHANGELOG.md) for behavior changes — 1.0.20 changed how `prepare()` and `linkAccountAsync()` report failures, which affects existing integrations. **Test Device**: Use a dedicated test iPhone with your Sandbox Apple Account signed in. Avoid mixing production Apple IDs on the same hardware to prevent authentication conflicts. ## Step 1: Add the SDK We recommend **Swift Package Manager** for most projects. If you cannot use SPM, install the SDK manually as an XCFramework instead. ### Option A: Swift Package Manager (Recommended) 1. **In Xcode, choose File → Add Package Dependencies…** 2. **Enter the package URL**: `https://github.com/koardlabs/koard-ios.git` 3. **Set the dependency rule** to "Up to Next Major Version" starting from `1.0.21` 4. **Add the `KoardSDK` library product** to your app target You can also add it directly to a `Package.swift`: ```swift .package(url: "https://github.com/koardlabs/koard-ios.git", from: "1.0.21") ``` Then list `KoardSDK` as a dependency of your target. ### Option B: Manual XCFramework 1. **Download the latest `KoardSDK.xcframework.zip`** from the [Releases page](https://github.com/koardlabs/koard-ios/releases) or get it directly from the team 2. **Extract the ZIP file** to reveal the `KoardSDK.xcframework` bundle 3. **Drag `KoardSDK.xcframework` into your Xcode project** (or use your target's **General → Frameworks, Libraries, and Embedded Content → "+" → Add Other… → Add Files…**) 4. **Ensure "Copy items if needed" is checked** and the framework is added to your app target **Note**: The XCFramework format ensures compatibility across different architectures (iOS Simulator, iOS Device) and simplifies distribution compared to traditional frameworks. ### Option C: CocoaPods The SDK ships a podspec (`KoardSDK`), which vendors the same `KoardSDK.xcframework`. Add it to your `Podfile`: ```ruby pod 'KoardSDK', :git => 'https://github.com/koardlabs/koard-ios.git', :tag => '1.0.21' ``` Then run `pod install` and open the generated `.xcworkspace`. ## Step 2: Configure Linking The released KoardSDK 1.0.21 XCFramework contains a static binary. For a manual install, add it under your target's **Frameworks, Libraries, and Embedded Content** and select **Do Not Embed**. Xcode links the archive into your app; it must not be copied into the app as a dynamic framework. Swift Package Manager handles linking the binary target. Do not add a second manual copy when using the package. ## Step 3: Verify Installation ### Check Framework Integration 1. **Build your project** (⌘+B) to ensure there are no compilation errors 2. **Verify the package or framework appears** in your project navigator 3. **For a manual install, check the framework is listed** under "Frameworks, Libraries, and Embedded Content" ### Test Basic Import Add this import statement to one of your Swift files to verify the SDK is accessible: ```swift import KoardSDK ``` If the import succeeds without errors, your installation is working correctly. ## Step 4: Initialize the SDK Once the SDK is installed, initialize it early in your app lifecycle with your `KoardOptions` and [API key](/docs/appendix/glossary#api-key "Secret header credential for REST calls; v5 keys use scoped permissions."): ```swift import KoardSDK // Configure SDK options let options = KoardOptions( environment: .uat, // .uat | .production | .custom(String) loggingLevel: .debug // .none | .error | .warning | .debug | .verbose ) // Initialize with your API key KoardMerchantSDK.shared.initialize(options: options, apiKey: "your-koard-api-key") ``` For details on retrieving your key, see [Retrieving Your API Key](/docs/setting-up-the-ios-sdk/retrieving-your-api-key). For authentication and payments, see [Running Payments](/docs/setting-up-the-ios-sdk/running-payments). ## Troubleshooting ### Common Installation Issues * **Build Errors**: Ensure you're using Xcode 16.3 or later * **Runtime Crashes**: For a manual XCFramework install, verify the static framework is linked and set to "Do Not Embed" * **Import Errors**: Check that the package product (or XCFramework) was added to the correct target, and that you `import KoardSDK` * **Architecture Issues**: The XCFramework automatically handles different architectures ### Verification Checklist * [ ] Xcode 16.3 or later installed * [ ] iOS 17.4+ deployment target set * [ ] `KoardSDK` added via Swift Package Manager, or `KoardSDK.xcframework` added and set to "Do Not Embed" * [ ] Project builds without errors * [ ] `import KoardSDK` works correctly ## Next Steps Once the SDK is installed and configured: * [Create a Sandbox Apple Account](/docs/setting-up-the-ios-sdk/creating-a-sandbox-apple-account) - Ensure your testers are ready * [Configure Tap to Pay](/docs/setting-up-the-ios-sdk/adding-support-for-tap-to-pay-on-iphone) - Enable contactless payment functionality * [Implement Payments](/docs/setting-up-the-ios-sdk/running-payments) - Add payment processing to your app * [Understand Payment Lifecycle](/docs/payments/payment-lifecycle) - Learn about the complete payment flow * [Get Ready for Production](/docs/setting-up-the-ios-sdk/getting-ready-for-production) - Set up schemes and API keys for launch ## See also This wraps up the SDK installation. See the links below for next steps in your integration: * [Creating a Sandbox Apple Account](/docs/setting-up-the-ios-sdk/creating-a-sandbox-apple-account) - Set up dedicated testers * [Adding Tap to Pay](/docs/setting-up-the-ios-sdk/adding-support-for-tap-to-pay-on-iphone) - Enable contactless payments * [Running Payments](/docs/setting-up-the-ios-sdk/running-payments) - Implement payment processing * [Payment Lifecycle](/docs/payments/payment-lifecycle) - Complete payment flow guide * [Apple Best Practices](/docs/appendix/apple-best-practices-and-guidelines) - iOS development guidelines # Getting Ready for Production Prepare your Koard integration to move beyond the Sandbox by configuring Xcode schemes, build configurations, and [API keys](/docs/appendix/glossary#api-key "Secret header credential for REST calls; v5 keys use scoped permissions.") for both development and production. **What You Learn** In this guide, you'll learn how to: * Create separate build configurations and schemes in Xcode * Inject environment-specific Koard API keys and endpoints * Automate build-time switching between Sandbox and production settings * Validate your production readiness checklist before submitting to the App Store **Prerequisites** Before you begin, ensure you have: * **Koard API keys** for both Sandbox and production * **Info.plist or `.xcconfig` access** to store environment values * **Xcode project admin access** to edit schemes and build settings * **Dedicated test devices** with [Sandbox Apple Accounts](/docs/appendix/glossary#sandbox-apple-account "Dedicated Apple tester account on a test iPhone for certification and QA.") for final verification ## 1. Duplicate Build Configurations 1. In Xcode, select your project in the Project Navigator. 2. Under **PROJECT → Info**, duplicate your existing `Debug` and `Release` configurations. Name the copies `Debug-Prod` and `Release-Prod`. 3. Point the production build configurations to `.xcconfig` files (optional but recommended) such as `Koard-Dev.xcconfig` and `Koard-Prod.xcconfig`. ```text // Xcode .xcconfig: $() prevents // from starting a comment. Koard-Dev.xcconfig KOARD_API_BASE_URL = https:/$()/api.uat.koard.com KOARD_API_KEY = ${KoardSandboxAPIKey} Koard-Prod.xcconfig KOARD_API_BASE_URL = https:/$()/api.koard.com KOARD_API_KEY = ${KoardProductionAPIKey} ``` Keep credentials out of source control. Build settings and Info.plist values are readable from a shipped app, even when injected through CI. Only use the intended scoped SDK client key in the app; privileged REST API keys belong on your backend. ## 2. Customize Schemes for Each Environment Following Apple’s [Customizing the Build Schemes](https://developer.apple.com/documentation/xcode/customizing-the-build-schemes-for-a-project) guidance, create two top-level schemes: * `KoardApp-Dev` mapped to the `Debug`/`Release` configurations * `KoardApp-Prod` mapped to the `Debug-Prod`/`Release-Prod` configurations 1. Open **Product → Scheme → Manage Schemes**. 2. Duplicate your primary scheme and rename it `KoardApp-Prod`. 3. Assign the correct build configuration for each action (Build, Run, Archive, etc.). 4. Uncheck **Shared** while iterating, then re-enable sharing once the configuration is stable so teammates receive the new scheme in source control. **Tip**: Keep the production scheme archived with **Release-Prod** to ensure App Store submissions always use production endpoints and credentials. ## 3. Switch API Keys at Build Time Expose the Koard API key and environment to your app using Info.plist substitutions or Swift build flags. ### Option A: Info.plist placeholders 1. Add keys like `KOARD_API_BASE_URL` and `KOARD_API_KEY` to your Info.plist. 2. Reference them using `${KOARD_API_BASE_URL}` placeholders. 3. Resolve them in code at runtime: ```swift struct KoardAppConfig { static let baseURL: URL = { guard let urlString = Bundle.main.object(forInfoDictionaryKey: "KOARD_API_BASE_URL") as? String, let url = URL(string: urlString) else { fatalError("Missing or invalid KOARD_API_BASE_URL") } return url }() static let apiKey: String = { guard let key = Bundle.main.object(forInfoDictionaryKey: "KOARD_API_KEY") as? String else { fatalError("Missing KOARD_API_KEY") } return key }() } ``` ### Option B: Swift compilation conditions 1. Add custom flags in **Build Settings → Swift Compiler - Custom Flags** (e.g., `-DKOARD_ENV_SANDBOX` and `-DKOARD_ENV_PRODUCTION`). 2. Use those flags to branch logic: ```swift #if KOARD_ENV_PRODUCTION let environment: KoardEnvironment = .production let loggingLevel: KoardLoggingLevel = .error #else let environment: KoardEnvironment = .uat let loggingLevel: KoardLoggingLevel = .debug #endif let options = KoardOptions( environment: environment, loggingLevel: loggingLevel ) KoardMerchantSDK.shared.initialize(options: options, apiKey: apiKey) ``` Use this approach when you prefer compile-time enforcement of environment differences, such as disabling test-specific UI in production builds. **Environment and logging**: `KoardOptions.environment` accepts `.uat`, `.production`, or `.custom(String)`. Lower the `loggingLevel` for production builds (for example `.error` or `.none`) so debug logs don't ship to release users. ## 4. Verify Device and Account Setup * Install the **Dev** build on a dedicated test iPhone that remains signed in with your Sandbox Apple Account. * Install the **Prod** build on a separate device or reset the test device before signing in with the production Apple ID. * Run smoke tests for [Tap to Pay](/docs/appendix/glossary#tap-to-pay "Apple contactless acceptance on iPhone without extra hardware."), refunds, reversals, and settlement cutovers in each environment. **Deploy Deliberately**: Never archive or submit to App Store Connect using a Sandbox scheme. Require production code reviews to confirm the `KoardApp-Prod` scheme was used for the final archive. **Authentication and Keychain**: Login is session-token only — the SDK persists the session token and never stores the merchant code, PIN, or alias. `logout()` clears only the SDK's own Keychain entries (it no longer performs a blanket delete of the host app's Keychain items), so signing out will not disturb other credentials your app stores. ## 5. Pre-launch Checklist * [ ] Xcode schemes map to the correct build configurations. * [ ] Production API keys and endpoints live outside of source control. * [ ] Privileged backend API keys remain on your server; the app uses the intended scoped SDK client key. * [ ] Sandbox and production devices are authenticated with the appropriate Apple IDs. * [ ] A rollback plan is documented in case production rollout needs to be paused. ## Next Steps * [Creating a Sandbox Apple Account](/docs/setting-up-the-ios-sdk/creating-a-sandbox-apple-account) to keep development builds isolated. * [Running Payments](/docs/setting-up-the-ios-sdk/running-payments) to validate production behavior before launch. * Coordinate with your Koard partner manager to schedule final Apple certification checks. # Launching on the App Store Everything you need to do with Apple — separate from your Koard integration — before your app can go live to merchants. **What you'll learn** In this guide, you'll learn: * The 7-step launch journey and where most teams get stuck * How to pick your distribution path before you write a line of code * The two entitlements you need (and why TestFlight requires the second one) * In-app experience requirements Apple inspects during review * The `prepare()` call and why it must happen at launch, not at checkout * The three video walkthroughs Apple requires — and how to record them * What to include in your App Store Connect submission notes * The most common rejection reasons and how to avoid every one **This is separate from your Koard integration.** Even after your Koard integration is technically complete, you cannot launch your app to merchants without Apple's approval. That approval is a separate, multi-step process you run directly with Apple. Most customers who reach this stage without a plan lose 2–6 weeks to back-and-forth with Apple Review. Track your progress in **Launch Readiness** inside the [Koard MMS](/docs/appendix/glossary#mms "Koard portal for merchants, terminals, locations, credentials, and API keys.") — it mirrors this guide step by step. ## Why this matters Four of the most common rejection reasons we see from Apple Review: 1. Submission videos don't show how a merchant accepts Apple's Terms & Conditions or links the merchant account. 2. No in-app merchant education — the developer planned to train merchants 1:1 in person. Apple requires _in-app_ education on top of whatever else you do. 3. The `prepare()` call isn't implemented, which means the first tap-to-pay attempt in front of a real customer takes 30–40 seconds. 4. Button copy mixes "Tap to Pay" with "[Tap to Pay on iPhone](/docs/appendix/glossary#tap-to-pay "Apple contactless acceptance on iPhone without extra hardware.")" — Apple is strict about the full product name. None of that is a Koard problem. All of it is an Apple-side gate, and it routinely costs developers weeks they didn't plan to spend. This guide and the in-MMS checklist exist so you find these things in week one of design — not in week one of submission. ## The launch journey Each step is a distinct gate. You cannot skip any of them. **1. Request the Development Entitlement** Free and near-instant. Lets you build and test on developer-registered devices against the [PSP](/docs/appendix/glossary#psp "Company that serves multiple merchants with payment processing.") sandbox. [Request at Apple's developer portal](https://developer.apple.com/contact/request/tap-to-pay-on-iphone) **2. Build to spec** Use this guide and the Koard MMS checklist to build the required UX — awareness moment, merchant education, T\&C flow, and checkout experience. Don't skip any section. **3. Record the three walkthrough videos** Apple requires a New User, Existing User, and Checkout walkthrough. These cannot be screen-recorded — use a second device to film the screen. **4. Request the Publishing Entitlement** Reply to the email Apple sent you when they granted the Dev entitlement. Apple reviews your app against the v1.5.1 checklist. Allow approximately 5 business days. **TestFlight requires this entitlement.** You cannot distribute via TestFlight until Publishing is granted. There is no shortcut. **5. Submit to App Store Connect** Separate review by the App Store team on top of the entitlement review. Include your test account credentials, video links, wireframes, and entitlement declaration in the submission notes. **6. Pilot** Apple recommends testing with a small group of representative merchants before GA. Use TestFlight or ship behind a feature flag you control. **7. General Availability** Flip your backend flag or publish your App Store listing with Tap to Pay enabled. Update your product page messaging only after the flag is live. ## Pick your distribution path first Apple has different requirements depending on how your app reaches merchants. Decide before you design any screens — the distribution path determines which checklist items are required for you. | Path | Who it's for | Onboarding | Awareness in app | Merchant education | | --------------------- | ---------------------------------------------- | -------------------------------- | ----------------------------------------- | ------------------ | | **Public App Store** | SMBs, anyone discovering your app via search | Required to be in-app, < 15 min | Required (full-screen modal, push, email) | Required | | **Unlisted App** | Specific BYO-device audience reached via URL | External OK | Recommended | Highly recommended | | **Custom App** | One named enterprise customer, branded version | External OK | Recommended | Highly recommended | | **Enterprise (ADEP)** | MDM-deployed to managed devices | External (no Apple ID on device) | Optional | Highly recommended | **Public App Store triggers the full in-app onboarding requirement.** Customers who pick this path because "we want it discoverable" often don't realize it requires a complete in-app merchant sign-up flow that works in under 15 minutes. **Most underused:** Custom App and Unlisted App, which carry lighter requirements for enterprise deployments. Apps that ship via MDM to managed devices are typically a fit for Unlisted. If you're not sure, the Koard solutions team can talk it through — but pick before you start designing screens. ## Two entitlements, not one | Entitlement | What it lets you do | How to get it | When | | --------------- | ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- | ---------------------------------- | | **Development** | Build, run on developer-registered devices, test against the PSP sandbox | [Request at Apple's developer portal](https://developer.apple.com/contact/request/tap-to-pay-on-iphone) | Day one | | **Publishing** | TestFlight, App Store, internal enterprise distribution | Reply to the email Apple sent you with the Dev entitlement. Apple reviews your app against the v1.5.1 checklist. | Once your build meets requirements | **TestFlight requires the Publishing Entitlement.** If you planned to ship a small pilot via TestFlight first, that already requires you to pass Apple's full review. There is no shortcut. ## The in-app experience Apple requires Apple inspects four moments in your user flow. Each has its own checklist covered in the sections below. Together they form the bulk of the v1.5.1 review. **Digital onboarding (Public distribution only)** A new user who just downloaded your app must be able to apply to become an authorised merchant _inside the app_, on an iPhone, and — for the majority of approvable users — accept their first payment within 15 minutes. Practical interpretation: * An external "contact sales" form does not count as digital onboarding. * A `WKWebView` wrapper around your existing onboarding portal _does_ count. * If your KYC requires manual review, the path must still start in-app and use push/email/SMS to bring the user back. **Awareness & enrollment** At least one **awareness moment** — Apple's strong preference is a full-screen modal — must communicate to eligible users that Tap to Pay on iPhone exists in your app. New merchants see it during onboarding. Existing merchants see it on first login after the feature ships. Required elements: * A launch email (Apple has a template). * A push notification (Apple has a template). * A way to enroll _outside_ the checkout flow (in Settings or similar). * A way to enroll _from_ the checkout flow if the user taps "Tap to Pay" without being enrolled. * Only admin-class users can accept the T\&C. Non-admins must see a "contact your admin" message. **Don't forget existing users.** The awareness moment is required for both new merchants during onboarding and existing merchants on their first login after the feature ships. Apple rejects apps that only show it to new users. **Merchant education** This is the most frequently missed requirement. You must provide in-app education demonstrating how to accept payment with Tap to Pay on iPhone. _External training — 1:1 sessions, newsletters, training videos sent by email — does not satisfy this requirement on its own._ **The Koard SDK handles this for you.** The Koard SDK wraps Apple's [`ProximityReaderDiscovery`](https://developer.apple.com/documentation/ProximityReader/ProximityReaderDiscovery) API and surfaces Apple-designed education screens directly in your app. Wiring up the Koard SDK's merchant-education entry point satisfies Apple's checklist item 4.1\* in full — Apple's own language: _"If you use ProximityReaderDiscovery this will fulfill all of the merchant education requirements."_ No need to design your own screens. Two things you still need to handle yourself: 1. **Make education reachable later.** The post-enrollment moment is covered by the SDK, but Apple also requires that users can find the education screens from Settings or Help (checklist item 4.2). Add a "Tap to Pay on iPhone" row in Settings that re-invokes the SDK's education flow. 2. **Cover region-specific requirements if applicable.** If you deploy in a PIN-required region, your education must demonstrate PIN entry and its accessibility features (4.6). If you deploy in a Fallback-required region, demonstrate the fallback payment method (4.7). If you're targeting iOS earlier than 18, you must build your own education screens using Apple's Marketing Guide and Toolkit assets covering, at minimum: * How to accept a contactless card (landscape position, top of iPhone) * How to accept Apple Pay and other digital wallets * PIN entry + accessibility (region-dependent) * Fallback payment method (region-dependent) **Transaction experience** The "Tap to Pay on iPhone" button at checkout must: * Be in a prominent, no-scroll location. * Use exact copy: `"Tap to Pay on iPhone"` (or `"Tap to Pay"` only if the button is too small; `"Charge"` only if Tap to Pay is your sole acceptance method). * **Never** be greyed out or hidden based on enrollment status — if the user isn't enrolled, tapping starts enrollment. * Use the `wave.3.right.circle` or `wave.3.right.circle.fill` SF Symbol if using an icon. After a successful tap: show a processing screen, then a clear approved/declined/timed-out result, and a digital receipt option (SMS, email, QR, or iOS Share). **Button copy is strictly enforced.** Apple is literal — "Tap to Pay" and "Tap to Pay on iPhone" are not interchangeable. Use the full name unless space genuinely does not allow it. ## The `prepare()` call — don't skip this Call `KoardMerchantSDK.shared.prepare()` after initialization, merchant login, and active-location selection, before the customer needs to tap. Preparation time depends on the device, network, and merchant profile. Display inline progress and leave payment controls disabled until the reader is ready. On foreground return, inspect readiness and use your preparation coordinator to avoid overlapping tasks. Do not discard preparation errors with `try?`: an unlinked account needs an explicit terms-acceptance action using `linkAccountAsync()`, followed by preparation. Reader-token and other errors need their own visible recovery path. See [Running Payments](/docs/setting-up-the-ios-sdk/running-payments#prepare-a-card-reader-session) for the published SDK calls and [Apple Best Practices](/docs/appendix/apple-best-practices-and-guidelines#reader-management) for a preparation coordinator. ## Terms & Conditions: two paths There are exactly two ways a merchant can accept Apple's Tap to Pay T\&C. Pick one based on your distribution path. **User-led (default)** In-app, the merchant signs into their Apple Account, taps "Accept", and the device kicks off terminal-profile building. This is the path for Public, Unlisted, and Custom App distribution where merchants have their own Apple ID on the device. **Never cache the T\&C status locally.** Always read T\&C acceptance state from Apple via the Koard SDK. A local flag can fall out of sync with Apple's records and cause unexpected T\&C prompts at the worst possible moment. **Enterprise (Apple Business Connect)** For MDM-managed devices without an Apple ID, an organisation admin accepts terms on behalf of the merchant via **Apple Business Connect**. Koard provides the token your admin uses to do this — talk to your Koard solutions contact to set it up _before_ you deploy devices. If the merchant hasn't linked in ABC, the user will see the T\&C prompt on the device anyway. This is the path most MDM-deployed enterprise apps take. It's documented in Apple's v1.5.1 checklist item 3.8.2. ## The three required videos Apple requires three video walkthroughs as part of the Publishing Entitlement review. **You must record with a second device.** The Tap to Pay on iPhone reader screen cannot be screen-recorded. Use a separate iPhone to film the device under test. **New User Flow** Show each of the following in order — Apple rejects videos that skip or cut any step: 1. Account creation 2. KYC (if applicable) 3. Merchant approval 4. Tap to Pay awareness moment 5. T\&C acceptance 6. Merchant education 7. Terminal profile configuration progress indicator 8. Completed indicator 9. One full transaction **Existing User Flow** Show each of the following in order: 1. Sign in to existing account 2. Tap to Pay button visible before T\&C is accepted 3. Awareness moment for existing users 4. T\&C acceptance 5. Merchant education 6. Progress indicator 7. One full transaction 8. PIN entry (if applicable for your region) 9. Fallback payment method (if applicable for your region) **Checkout Flow** Show each of the following in order: 1. Add items to cart (or enter amount) 2. Payment options screen 3. Tap to Pay button 4. Initiate and complete a Tap to Pay transaction 5. PIN entry (if applicable) 6. Fallback (if applicable) **Common rejection:** the videos exist but skip a step ("we cut to after T\&C acceptance"). Re-record showing every step in sequence. If you need to unlink your Apple Account to re-record T\&C acceptance, Apple has documentation on resetting the T\&C state. ## Submitting to App Store Connect For Public, Unlisted, or Custom App distribution, passing the Publishing Entitlement review is not the end. Your app then goes through standard App Store Review _plus_ a Tap to Pay-specific review. In your App Store Connect submission notes, include: * A declaration that you are using the Tap to Pay on iPhone entitlement. * A description of your use case (e.g. "a point-of-sale app for SMB merchants"). * **A test account that works.** Apple says "a vast majority of rejections are due to test accounts not working properly." Validate on a fresh device before submitting. * A link to your video walkthrough _and_ high-fidelity wireframes of your checkout experience. * If your app is geo-fenced or uses a feature flag for Tap to Pay — declare that explicitly. * If Tap to Pay is your _only_ acceptance method, set `UIRequiredDeviceCapabilities` to include `iphone-ipad-minimum-performance-a12` so incompatible devices cannot download the app. **Enterprise apps: do not mention MDM.** If you're MDM-distributing in the enterprise, do not mention MDM in your App Store Connect app details. It flags a different (incorrect) entitlement review. Route enterprise distribution via ADEP. **Respond on the same thread.** Apple will often respond to a missing piece as a "rejection" with a note in the body saying it's actually a request for information. Reply on the same message thread — don't open a new one. ## Common pitfalls | # | Pitfall | Catch it before submission by… | | -- | ---------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | | 1 | Submission videos skip T\&C / merchant-linking | Using the Videos checklist in MMS Launch Readiness — it lists every Apple-required step per video | | 2 | No in-app merchant education ("we train in person") | Wiring up the Koard SDK's merchant-education entry point (wraps `ProximityReaderDiscovery`, iOS 18+) — satisfies 4.x in one call | | 3 | `prepare()` not implemented or called at checkout | Implementing on day one at app launch and `applicationWillEnterForeground` | | 4 | Test account credentials don't work on Apple's review device | Validating on a fresh iPhone _before_ submitting | | 5 | Tap to Pay button uses wrong copy or greys out when not enrolled | Apple is literal — use "Tap to Pay on iPhone"; never grey the button | | 6 | Mentioned MDM in App Store Connect notes for an enterprise app | Strip the MDM mention; route enterprise via ADEP, not the App Store | | 7 | Awareness moment present but only for new users | Add an existing-user awareness moment too (full-screen modal on first login post-launch) | | 8 | T\&C local cache out of sync with Apple | Always read T\&C status from Apple via the Koard SDK — never trust a local flag | | 9 | Geo-fencing not declared in App Store Connect notes | Add a line: "App is geo-fenced to US, CA. Test account works in these regions." | | 10 | Marketing channels skipped (launch email, push, hero banner) | Items 6.1, 6.2, 6.3 — required for Public distribution | ## After approval: pilot, then GA Apple strongly recommends piloting with a small group of representative merchants before flipping to general availability. Two options: * **TestFlight** — invite-only, requires the Publishing Entitlement. * **Feature flag** — ship the binary to GA with Tap to Pay hidden behind a backend flag you control. This is the path Apple recommends for existing apps, because most users will already have the updated version when you flip the flag. If you go this route, declare it in your App Store Connect submission, and do not update your App Store product page with Tap to Pay messaging until you flip the flag. Apple ships a pilot questionnaire in the Getting Started PDF (Appendix). Use it. ## Quick reference links * [Apple Tap to Pay on iPhone developer site](https://developer.apple.com/tap-to-pay-on-iphone/) * [Request the entitlement](https://developer.apple.com/contact/request/tap-to-pay-on-iphone/) * [Human Interface Guidelines — Tap to Pay on iPhone](https://developer.apple.com/design/human-interface-guidelines/tap-to-pay-on-iphone) * [Apple Marketing Guidelines](https://developer.apple.com/tap-to-pay/marketing-guidelines/) * [ProximityReaderDiscovery API (iOS 18+)](https://developer.apple.com/documentation/ProximityReader/ProximityReaderDiscovery) * [App Store Review Guidelines](https://developer.apple.com/app-store/review/guidelines/) ## Where to get help * **In-MMS checklist** — `Launch Readiness` in your Koard dashboard mirrors this guide section by section. * **Apple entitlement questions** — reply on your existing `applepayentitlements@apple.com` thread, citing your Case ID. * **PSP-side questions** (T\&C from Apple vs local, ABC tokens for enterprise, `prepare()` semantics) — your Koard solutions contact. ## See also * [Setting Up the Entitlement for Tap to Pay on iPhone](/docs/setting-up-the-ios-sdk/setting-up-the-entitlement-for-tap-to-pay-on-iphone) - Request and configure the Apple development entitlement * [Adding Support for Tap to Pay on iPhone](/docs/setting-up-the-ios-sdk/adding-support-for-tap-to-pay-on-iphone) - Configure Xcode and run your first test transaction * [Getting Ready for Production](/docs/setting-up-the-ios-sdk/getting-ready-for-production) - Set up Xcode schemes and [API keys](/docs/appendix/glossary#api-key "Secret header credential for REST calls; v5 keys use scoped permissions.") for your production build * [Apple Best Practices and Guidelines](/docs/appendix/apple-best-practices-and-guidelines) - UX patterns, ProximityReader usage, and security practices * [Running Payments](/docs/setting-up-the-ios-sdk/running-payments) - SDK reference for payment flows _This guide reflects Apple's published requirements as of March 2025 (App Review Checklist v1.5.1) and September 2023 (Getting Started v1.2). Apple updates these documents periodically. If you see a discrepancy, the Apple source wins — please flag it to us._ # Retrieving Your API Key To retrieve your [API key](/docs/appendix/glossary#api-key "Secret header credential for REST calls; v5 keys use scoped permissions.") for your account, follow these steps: **1. Navigate to the Developer page** In the sidebar, click on ![link icon](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLWZpbGUtdGV4dC1pY29uIGx1Y2lkZS1maWxlLXRleHQiPjxwYXRoIGQ9Ik02IDIyYTIgMiAwIDAgMS0yLTJWNGEyIDIgMCAwIDEgMi0yaDhhMi40IDIuNCAwIDAgMSAxLjcwNC43MDZsMy41ODggMy41ODhBMi40IDIuNCAwIDAgMSAyMCA4djEyYTIgMiAwIDAgMS0yIDJ6Ii8+PHBhdGggZD0iTTE0IDJ2NWExIDEgMCAwIDAgMSAxaDUiLz48cGF0aCBkPSJNMTAgOUg4Ii8+PHBhdGggZD0iTTE2IDEzSDgiLz48cGF0aCBkPSJNMTYgMTdIOCIvPjwvc3ZnPg==) Developer to access the Developer Tools page. **2. Access API Keys** Once on the Developer page, you'll see the **API Keys** tab. Click on it to view your API key management section. **3. View your API key** Your API key will be displayed in the API Key Management section. You can: * View the masked API key * Click the eye icon to reveal the full key * Click the copy icon to copy the key to your clipboard ![API Key Management](/api.png) **Security Note**: Keep your API key confidential and never share it in client-side code or public repositories. Rotate and revoke keys regularly to maintain security. ## Using Your API Key in the SDK Pass your API key to the SDK when you initialize it, along with a `KoardOptions` value that selects the environment and logging level: ```swift import KoardSDK let options = KoardOptions( environment: .uat, // .uat | .production | .custom(String) loggingLevel: .debug // .none | .error | .warning | .debug | .verbose ) KoardMerchantSDK.shared.initialize(options: options, apiKey: "your-koard-api-key") ``` * **`environment`** accepts `.uat`, `.production`, or `.custom(String)` for a custom base URL. * **`loggingLevel`** accepts `.none`, `.error`, `.warning`, `.debug`, or `.verbose`. Use a lower level (such as `.error` or `.none`) for production builds. Call `initialize(options:apiKey:)` once, early in your app lifecycle (for example in `AppDelegate`), before using any payment functionality. # Running Payments These examples target the published **iOS SDK 1.0.21** and the standard merchant checkout flow: sale, preauth, refunds, and transaction management. [Install this version](/docs/setting-up-the-ios-sdk/installing-the-sdk) before using them. If you're ready to start developing, see our [SDK installation guide](/docs/setting-up-the-ios-sdk/installing-the-sdk). [Get started with Koard](/docs/introduction) **What you learn** In this guide, you'll learn: * How to initialize and authenticate with the Koard Merchant SDK * How to set up location management for multi-location merchants * How to prepare card reader sessions for tap-to-pay * How to process different types of transactions (sale, [preauth](/docs/appendix/glossary#preauth "Authorization hold without capture; must later capture or reverse."), refund) * How to handle transaction responses and error scenarios For custom order/refund references, see [Transaction Metadata](/docs/setting-up-the-ios-sdk/transaction-metadata). For `deinit()`, initialization status, and signing in after a reset, see [SDK Lifecycle and Deinitialization](/docs/setting-up-the-ios-sdk/sdk-lifecycle). ## Active Location and Terminal Routing **Your app must provide a way to select the active location in settings.** Authenticate the merchant, load that merchant's available locations, and let the operator select the location where the device is taking payments. The selected `location_id` resolves the linked `terminal_id`, whose processor configuration, MID, and TID determine payment routing for that merchant. Show the active location in settings and on the payment screen. Select it before reader preparation or any payment. If the operator changes merchants, clear the previous selection and reload the new merchant's locations. If you remember a choice between sessions, validate that it still belongs to the authenticated merchant and still has a usable linked terminal. Do not silently reuse another merchant's location or select an arbitrary terminal. * **iOS:** call `KoardMerchantSDK.shared.setActiveLocationID(location.id)`, then follow the account-linking and reader preparation flow. * **Android:** call the suspending `sdk.setActiveLocation(location.id)`, then `sdk.enrollDevice()` and the reader preparation flow. Block payment initiation until location selection and required readiness checks have completed. If a location's terminal changes, reload the configuration and complete the SDK's readiness flow before taking another payment. ## Before you begin This comprehensive guide covers everything you need to know about integrating and using the KoardMerchantSDK in your iOS application. For payment concepts and API payloads, explore the [Payments guides](/docs/payments/overview)—including [Sale](/docs/payments/methods/sale) and [Preauth](/docs/payments/methods/preauth). To understand the complete flow, see the [Payment Lifecycle guide](/docs/payments/payment-lifecycle). **Test on Real Hardware**: Keep a dedicated test iPhone with your [Sandbox Apple Account](/docs/appendix/glossary#sandbox-apple-account "Dedicated Apple tester account on a test iPhone for certification and QA.") signed in. Simulator builds cannot exercise [Tap to Pay](/docs/appendix/glossary#tap-to-pay "Apple contactless acceptance on iPhone without extra hardware."), and production Apple IDs won't work in the Sandbox environment. ## Key Concepts ### 1. Authentication Tokens The SDK manages several types of tokens automatically: * **[API Key](/docs/appendix/glossary#api-key "Secret header credential for REST calls; v5 keys use scoped permissions.")**: Your API key for Koard services * **Card Reader Token**: Apple's ProximityReader token for Tap to Pay functionality ### 2. Card Reader Sessions The SDK handles Apple's ProximityReader lifecycle: * **Preparation**: Refreshes tokens and prepares the reader for transactions * **Transaction Processing**: Manages card reading and data collection * **Session Management**: Handles background/foreground transitions automatically ### 3. Location Management Multi-location merchants must set an active location before processing payments: * **Retrieve available locations** after login * **Set the active location** for all subsequent transactions * **Location data is persisted** across app sessions ## Initialize the SDK Initialize the SDK early in your app lifecycle (typically in `AppDelegate` or `SceneDelegate`): ```swift import UIKit import KoardSDK class AppDelegate: NSObject, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { // Configure SDK options let options = KoardOptions( environment: .uat, // .uat | .production | .custom(String) loggingLevel: .debug // .none | .error | .warning | .debug | .verbose ) // Initialize with your API key KoardMerchantSDK.shared.initialize( options: options, apiKey: "your-koard-api-key" ) return true } } ``` ## Authenticate the Merchant Before processing any payments, authenticate the merchant. The login function returns a JWT token that is then passed in the Bearer token of all successive requests. ```swift import KoardSDK private func authenticateMerchant() async throws { do { // Login with merchant credentials try await KoardMerchantSDK.shared.login( code: "your-merchant-code", pin: "your-merchant-pin" ) print("Merchant authenticated successfully") // Load this merchant's locations into your app settings picker. // Call setupLocation(selectedLocationId:) after the operator selects one. } catch { print("Authentication failed: \(error)") throw error } } ``` If you have already resolved the merchant identity into a single opaque string (for example via a QR scan, SSO callback, or server-issued provisioning token), you can log in with an alias instead of a code and PIN. It produces the same session token: ```swift try await KoardMerchantSDK.shared.login(alias: "your-merchant-alias") ``` **Session-token auth**: Login is session-token only. The SDK persists the session token and never stores the merchant code, PIN, or alias. When the session expires, call `login(...)` again to re-authenticate. ## Set Location Retrieve and set the location selected in your app settings. Its linked terminal determines the MID, TID, and processor configuration used for payments. Revalidate a saved selection after login or a merchant change; never automatically select the first location. Block payments until the selected location belongs to the authenticated merchant and has a linked terminal. ```swift private enum LocationSelectionError: Error { case invalidSelection case noLinkedTerminal } private func setupLocation(selectedLocationId: String) async throws { do { let sdk = KoardMerchantSDK.shared let merchant = try await sdk.getMerchantAccount() let locations = try await sdk.locations(request: GetLocationsRequest( locationId: selectedLocationId, merchantId: merchant.id )) guard let activeLocation = locations.first(where: { $0.id == selectedLocationId && $0.accountId == merchant.id }) else { throw LocationSelectionError.invalidSelection } guard let terminalId = activeLocation.terminalId, !terminalId.isEmpty else { throw LocationSelectionError.noLinkedTerminal } sdk.setActiveLocationID(activeLocation.id) print("Active location set: \(activeLocation.name)") } catch { print("Location setup failed: \(error)") throw error } } ``` ## Prepare a Card Reader Session After login and active-location selection, call `prepare()`. If it throws `accountNotLinked`, show an explicit **Accept Tap to Pay terms** action. Await `linkAccountAsync()` from that action, then prepare again. Linking can be declined or fail; keep payments blocked until preparation succeeds. ```swift @MainActor private func prepareCardReader() async throws { try await KoardMerchantSDK.shared.prepare() } // Call from the operator's account-linking action, not a background retry. @MainActor private func linkAndPrepareCardReader() async throws { try await KoardMerchantSDK.shared.linkAccountAsync() try await prepareCardReader() } ``` The SDK owns the reader's event subscription. Display readiness by polling `KoardMerchantSDK.shared.status` while the payment view is visible, as shown in [Session Management](#session-management). Do not add a second `readerEvents` iterator. ## Process Sale Transactions Sale transactions are single-step auth + capture that immediately capture funds. Use sales when the final amount is known at payment time. For more details on when to use Sale vs Preauth, compare the [Sale](/docs/payments/methods/sale) and [Preauth](/docs/payments/methods/preauth) guides. ```swift @MainActor private func processSale(eventId: String) async throws { // Create payment breakdown (optional) let breakdown = PaymentBreakdown( subtotal: 1000, // $10.00 in cents taxRate: 8.75, // 8.75% as a percent value (not a 0-1 decimal) taxAmount: 88, // $0.88 in cents tipAmount: 200, // $2.00 in cents tipType: .fixed // or .percentage ) // Create currency let currency = CurrencyCode(currencyCode: "USD", displayName: "US Dollar") do { // Process the sale let response = try await KoardMerchantSDK.shared.sale( amount: 1288, // Total amount in cents breakdown: breakdown, // Optional breakdown currency: currency, eventId: eventId, // Persisted before submitting this operation type: .sale, // Defaults to .sale metadata: ["order_id": "order-123", "channel": "mobile"] ) // Handle the response try handleTransactionResponse(response) } catch { print("Sale failed: \(error)") throw error } } ``` **Tracking and [idempotency](/docs/appendix/glossary#idempotency "Safe retry pattern using event_id so network failures do not double-charge.")**: `sale` and `preauth` take an optional `eventId` (UUID4), not a `transactionId`. Koard generates the transaction ID and returns it on `response.transactionId`. The reader is driven internally, so a Tap to Pay sheet is presented during these calls. Persist each operation ID in your app’s order store before calling the SDK. The helpers accept those saved IDs; confirmations and second-card payments use their own IDs. If the response is lost, [reconcile the saved ID](/docs/payments/idempotency) before considering another submission. ## Process Preauthorization Transactions Preauthorization transactions authorize funds without capturing them. They can be incrementally authorized, captured, or reversed. Use preauth when the final amount is uncertain (e.g., restaurant with tip) or when you need to verify funds availability. To complete a preauth, capture the payment using the transaction ID. For the complete flow, see [Preauth](/docs/payments/methods/preauth) and [Capture](/docs/payments/methods/capture). ```swift @MainActor private func processPreauth(eventId: String) async throws { let currency = CurrencyCode(currencyCode: "USD", displayName: "US Dollar") do { // Process preauthorization (breakdown is optional — pass nil) let response = try await KoardMerchantSDK.shared.preauth( amount: 1000, // Amount to preauthorize in cents breakdown: nil, // Optional breakdown currency: currency, eventId: eventId, // Persisted before submitting this operation metadata: ["order_id": "order-123"] ) try handleTransactionResponse(response) // Store transaction ID for later capture/reverse UserDefaults.standard.set(response.transactionId, forKey: "lastPreauthId") } catch { print("Preauth failed: \(error)") throw error } } ``` ## Handle Transaction Responses Payment methods return a `TransactionResponse`. Read the rich domain object from `response.transaction` (a `KoardTransaction?`). Its `status` is the public `KoardTransaction.Status` enum, which includes `pending`, `authorized`, `captured`, `surchargePending`, `surchargeApplied`, `approved`, `declined`, `refunded`, `reversed`, `pickupCard`, `timedOut`, `canceled`, `cancelled`, `error`, `settled`, and `unknown`. ```swift private enum PaymentError: Error { case invalidResponse } private func requireTransaction(_ response: TransactionResponse) throws -> KoardTransaction { guard let transaction = response.transaction else { throw PaymentError.invalidResponse } return transaction } @MainActor private func handleTransactionResponse(_ response: TransactionResponse) throws { renderChargeResult(try requireTransaction(response)) } @MainActor private func renderChargeResult(_ transaction: KoardTransaction) { // A partial approval is not payment of the entire order. if transaction.isPartialApproval { requestPartialApproval(transaction) return } switch transaction.status { case .captured, .authorized, .approved, .surchargeApplied, .settled: showReceipt(transaction) case .surchargePending: requestSurchargeConfirmation(transaction) case .pending: reconcileTransaction(transaction) case .declined: showDeclined(transaction.statusReason ?? "Transaction declined") case .canceled, .cancelled: showCanceled("Transaction canceled") case .error, .timedOut, .pickupCard, .reversed, .refunded, .unknown: showUnresolved(transaction.statusReason ?? "Check the transaction outcome") @unknown default: showUnresolved("Unrecognized transaction status") } } @MainActor private func confirmSurcharge(transactionId: String, accepted: Bool, eventId: String) async throws { // eventId is the separately persisted cardholder-decision operation ID. let transaction = try await KoardMerchantSDK.shared.confirm( transaction: transactionId, confirm: accepted, eventId: eventId ) // confirm returns KoardTransaction directly, not TransactionResponse. renderChargeResult(transaction) } ``` The `show...`, `request...`, and `reconcileTransaction` functions are application UI callbacks. Save the transaction ID before displaying the next screen. For surcharge confirmation, display `surchargeDisclosure`, the surcharge, and the final total from the returned transaction; persist a new decision ID before calling `confirmSurcharge`. Pass `accepted: false` when the cardholder rejects the fee. See [pending amounts and expiry](/docs/payments/surcharging#pending-amounts-rejection-and-expiry) for the 120-second confirmation window and cancellation outcomes. An authorized preauth is a hold, so its receipt must say **authorized**, rather than captured or settled. Pending, missing, and partial results must retain their IDs for [outcome recovery](/docs/payments/idempotency); do not automatically submit another sale. ## Handle Partial Approvals When the issuer authorizes less than the requested amount, the transaction comes back with a `statusReason` of `partial_approval` (the `StatusReason.partialApproval` case). The top-level `status` still reads as `approved`/`captured`. Use the `partialAuthApproval` method to accept the partial amount as final, or reject it to release the hold: ```swift private func handlePartialApproval(_ transaction: KoardTransaction, eventId: String) async throws { guard transaction.isPartialApproval else { return } // remainingAmount is the amount still owed after the partial authorization if let remaining = transaction.remainingAmount { print("Partial approval — remaining balance: \(remaining) cents") } // Accept the partial amount as final (or pass approve: false to release the hold) let settled = try await KoardMerchantSDK.shared.partialAuthApproval( transactionId: transaction.transactionId, approve: true, eventId: eventId // Persisted partial-approval decision ID ) print("Partial-approval decision status: \(settled.status.string)") } ``` To collect the remaining balance on a second card, pass the original transaction's id as `partialAuthTransactionId` on the follow-up `sale` (or `preauth`). This links the second-card payment to the original partial approval. It opens a new Tap to Pay read; inspect the remainder's returned transaction too: ```swift let remainder = try await KoardMerchantSDK.shared.sale( amount: remainingAmount, breakdown: nil, currency: CurrencyCode(currencyCode: "USD", displayName: "US Dollar"), eventId: remainderEventId, // Separately persisted second-card operation ID partialAuthTransactionId: transaction.transactionId ) ``` ## Transaction Management These helpers return the actual `KoardTransaction`. The caller must inspect its `status` and operation amounts before showing success. A returned API response alone is not an approval; pending outcomes need reconciliation. ### Refund a Transaction ```swift import KoardSDK private func processRefund(transactionId: String, eventId: String, amount: Int? = nil) async throws -> KoardTransaction { do { let response = try await KoardMerchantSDK.shared.refund( transactionId: transactionId, amount: amount, // nil for full refund eventId: eventId, // Persisted refund operation ID metadata: ["order_id": "order-123", "reason": "customer_return"] ) return try requireTransaction(response) } catch { print("Refund failed: \(error)") throw error } } ``` To run a card-present refund (presenting the Tap to Pay sheet and capturing card data with the refund), pass `withTap: true`. When `withTap` is set, an `amount` is required: ```swift let response = try await KoardMerchantSDK.shared.refund( transactionId: transactionId, amount: 1288, // Required when withTap is true eventId: eventId, withTap: true ) ``` ### Reverse a Preauthorization ```swift private func reversePreauth(transactionId: String, eventId: String, amount: Int? = nil) async throws -> KoardTransaction { do { let response = try await KoardMerchantSDK.shared.reverse( transactionId: transactionId, amount: amount, // nil for full reversal eventId: eventId ) return try requireTransaction(response) } catch { print("Reversal failed: \(error)") throw error } } ``` **Note**: Transactions can be partially reversed and refunded. Inspect the returned status and cumulative reversed/refunded amounts. Do not infer cancellation solely from a zero remaining amount; REST uses `canceled`, while SDK enums can include legacy aliases. For more details, see our [Payment Lifecycle guide](/docs/payments/payment-lifecycle). ### Incremental Authorization Authorize additional amounts on an existing preauth transaction. This is useful for adding incidental charges (e.g., hotel mini bar, additional restaurant items): ```swift private func incrementalAuth(transactionId: String, additionalAmount: Int, eventId: String) async throws -> KoardTransaction { do { let response = try await KoardMerchantSDK.shared.auth( transactionId: transactionId, amount: additionalAmount, breakdown: nil, eventId: eventId ) return try requireTransaction(response) } catch { print("Incremental auth failed: \(error)") throw error } } ``` ### Capture a Transaction Capture a previously authorized preauth transaction. You can capture the full authorized amount or a partial amount (e.g., adjust for final tip): ```swift private func captureTransaction(transactionId: String, eventId: String, finalAmount: Int? = nil) async throws -> KoardTransaction { do { let response = try await KoardMerchantSDK.shared.capture( transactionId: transactionId, amount: finalAmount, // nil to capture full authorized amount breakdown: nil, // Supply a breakdown matching the final amount if needed eventId: eventId ) return try requireTransaction(response) } catch { print("Capture failed: \(error)") throw error } } ``` ### Adjust the Tip Adjust the tip on a completed transaction (for example, after a customer finalizes a tip in table service): ```swift private func adjustTip(transactionId: String, newTipTotal: Int, eventId: String) async throws -> KoardTransaction { let response = try await KoardMerchantSDK.shared.tipAdjust( transactionId: transactionId, amount: newTipTotal, // New tip amount in cents tipType: .fixed, // .fixed or .percentage (PaymentBreakdown.TipType) eventId: eventId // Persisted tip-adjust operation ID ) return try requireTransaction(response) } ``` ### Send a Receipt Deliver a receipt for a completed transaction by email, SMS, or both. Pass at least one of `email` / `phoneNumber`: ```swift private func sendReceipt(transactionId: String) async throws { let response = try await KoardMerchantSDK.shared.sendReceipts( transactionId: transactionId, email: "customer@example.com", phoneNumber: "+15551234567" ) print("Receipt delivery: \(response)") } ``` ### Create a Fallback Payment Link When a tap cannot complete — an unsupported card, a reader problem, or a customer who would rather pay on their own device — generate a hosted payment link for the same amount: ```swift private func createFallback(amount: Int, breakdown: PaymentBreakdown?) async throws { let fallback = try await KoardMerchantSDK.shared.createFallbackLink( amount: amount, breakdown: breakdown ) print("Fallback link: \(fallback)") // Share the link with the customer (SMS, email, or QR code) } ``` ## Transaction History The SDK provides methods to retrieve and filter transaction history: ```swift import KoardSDK private func getTransactionHistory() async throws { do { // Get recent transactions let history = try await KoardMerchantSDK.shared.transactionHistory() print("Found \(history.transactions.count) transactions") // Filter by status let approvedTransactions = try await KoardMerchantSDK.shared.transactionsByStatus("approved") // Search transactions let searchResults = try await KoardMerchantSDK.shared.searchTransactions("card_number_here") // Advanced filtering let filteredTransactions = try await KoardMerchantSDK.shared.searchTransactionsAdvanced( startDate: Date().addingTimeInterval(-86400 * 7), // Last 7 days endDate: Date(), statuses: ["approved", "declined"], types: ["sale", "refund"], minAmount: 100, // $1.00 maxAmount: 10000, // $100.00 limit: 50 ) } catch { print("Transaction history failed: \(error)") throw error } } ``` **Note**: For webhook-based transaction monitoring, see our [Available Events guide](/docs/webhooks/available-events). ## Error Handling SDK calls throw `KoardMerchantSDKError`. Use its `errorDescription` property for a user-facing message. Match the cases you care about: ```swift private func handleSDKError(_ error: Error) { if let koardError = error as? KoardMerchantSDKError { switch koardError { case .missingLocationID: print("No active location set") // Prompt user to select location case .accountNotLinked: print("Tap to Pay account is not linked") // Show the account-linking action; it awaits // linkAndPrepareCardReader() and handles any thrown error. case .readerTokenInvalid(let message): print("Reader token invalid: \(message ?? "retry preparation")") // Usually transient — retrying prepare() recovers case .unauthorized: print("Not authenticated or session expired") // Redirect to login case .blockedAccount: print("This merchant account is blocked") case .rateLimited(let message): print("Rate limited: \(message ?? "slow down and retry")") // Back off and retry later case .network(let description, _): print("Network error: \(description)") case .server(let message): print("Server error: \(message ?? "try again later")") case .TTPPaymentFailed(.canceled): print("Customer canceled at the Tap to Pay sheet") // Benign outcome — treat as canceled, not a failure case .TTPPaymentFailed(let ttpError): print("Tap to Pay error: \(ttpError)") // Handle other reader errors case .invalidParameters(let message): print("Invalid parameters: \(message)") default: print("Koard SDK error: \(koardError.errorDescription)") } } else { print("General error: \(error)") } } ``` **Error type**: The SDK's error type is `KoardMerchantSDKError`. Earlier releases added `.rateLimited(message:)` for HTTP 429 and `TTPPaymentError.canceled` for Tap to Pay sheet cancellations. Network/transport failures are thrown as `.network(description:underlying:)` rather than raw `URLError`, and a missing session on a payment/refund/pre-auth throws `.unauthorized`. **Upgrading to 1.0.20**: Two behavior changes require code updates.\ \ **1. `prepare()` no longer auto-links the Tap to Pay account.** It now throws `KoardMerchantSDKError.accountNotLinked` instead. Guard for that case and drive linking explicitly with `linkAccount()` (or `linkAccountAsync()`), then call `prepare()` again.\ \ **2. `linkAccountAsync()` now throws** when linking fails or is declined — previously it never threw. Wrap it in `try`/`catch` and keep showing your "link account" prompt on failure.\ \ `prepare()` can also throw the new `.readerTokenInvalid`; a retry typically recovers. ## Session Management The SDK manages the reader lifecycle. The standard demo polls the public status rather than subscribing to the reader event stream. A SwiftUI `.task` stops when its view disappears, so polling does not accumulate across navigation: ```swift import SwiftUI import KoardSDK struct ReaderStatusView: View { @State private var statusMessage = "Reader not ready" @State private var isReady = false var body: some View { Text(statusMessage) .accessibilityValue(isReady ? "Ready" : "Not ready") .task { @MainActor in while !Task.isCancelled { let status = KoardMerchantSDK.shared.status statusMessage = status.info isReady = status.isReady do { try await Task.sleep(nanoseconds: 250_000_000) } catch { return } } } } } ``` Use readiness to gate the payment button. Prepare after login, a location change, or a readiness failure, and handle `accountNotLinked` through the operator's explicit linking action. Avoid launching overlapping preparation tasks when the app returns to the foreground. ## Logout and Cleanup `logout()` signs out the merchant while leaving the SDK initialized. To also clear the SDK API key and require initialization again, call `KoardMerchantSDK.shared.deinit()`. Read `KoardMerchantSDK.shared.isInitialized` to display that state. See [SDK Lifecycle and Deinitialization](/docs/setting-up-the-ios-sdk/sdk-lifecycle) for the reset sequence and enrollment behavior. ```swift private func logout() { // Clear all session data KoardMerchantSDK.shared.logout() print("Logged out successfully") // Redirect to login screen } ``` ## Best Practices ### SDK Management * **Token Management**: The SDK handles all token refresh automatically * **Error Handling**: Always wrap SDK calls in try-catch blocks * **Background Handling**: The SDK manages background transitions automatically * **Session Preparation**: Call `prepare()` before each payment session ### Payment Processing * **Amount Formatting**: Always use minor currency units (e.g., 1050 cents for $10.50) * **Include Breakdowns**: Provide detailed breakdowns for accurate tax and tip reporting * **Location Setting**: Set active location before any payment operations * **Store Transaction IDs**: Save transaction IDs for all follow-up operations ### User Experience * **Monitor Reader Status**: Read the SDK status for readiness; leave its reader event subscription to the SDK * **Handle All States**: Implement handlers for all transaction states * **Provide Clear Feedback**: Show clear messages for declined or failed transactions ### Gateway Considerations * **Know Your Gateway**: Different gateways (TSYS, Payroc) have different features * **Batch Management**: Understand your gateway's batch requirements * **Response Codes**: Response codes vary by gateway For more best practices, see: * [Payment Lifecycle guide](/docs/payments/payment-lifecycle) * [Sale](/docs/payments/methods/sale) and [Preauth](/docs/payments/methods/preauth) * [Capture](/docs/payments/methods/capture) and [Refund](/docs/payments/methods/refund) ## Troubleshooting ### Common Issues **Account Linking Issues** * Ensure device has iCloud account configured * Verify device has passcode enabled * Check that device supports Apple Tap to Pay on iPhone **Token Expiration** * SDK automatically refreshes tokens * Check network connectivity * Verify API key is valid **Card Reader Not Ready** * Call `prepare()` before processing payments * Ensure merchant is authenticated with `login()` * Check that account is linked with `isAccountLinked()` **Missing Location** * Verify location is set with `setActiveLocationID()` * Ensure location has valid terminal configuration * Check that location belongs to authenticated merchant **Transaction Errors** * Check transaction state before performing operations * Verify amounts are within valid ranges * Review gateway response for detailed error information For more troubleshooting help, see: * [Payment Lifecycle guide](/docs/payments/payment-lifecycle) * [Incremental Auth](/docs/payments/methods/incremental-auth) and [Tip Adjust](/docs/payments/methods/tip-adjust) * [Reverse](/docs/payments/methods/reverse) and [Refund](/docs/payments/methods/refund) ## Requirements * **iOS 17.4+** * **Xcode 16.3+** * **Swift 5.9+** ## See also This wraps up payment processing with the iOS SDK. See the links below for next steps in your integration: * [Payment Lifecycle](/docs/payments/payment-lifecycle) - Complete payment flow guide * [Adding Tap to Pay](/docs/setting-up-the-ios-sdk/adding-support-for-tap-to-pay-on-iphone) - Enable contactless payments * [Installing the SDK](/docs/setting-up-the-ios-sdk/installing-the-sdk) - SDK installation guide * [Creating a Sandbox Apple Account](/docs/setting-up-the-ios-sdk/creating-a-sandbox-apple-account) - Maintain test identities * [Getting Ready for Production](/docs/setting-up-the-ios-sdk/getting-ready-for-production) - Switch schemes and API keys * [Apple Best Practices](/docs/appendix/apple-best-practices-and-guidelines) - iOS development guidelines # SDK Lifecycle and Deinitialization `deinit()` ends the SDK's current setup so your app can initialize it again without restarting. Use it when replacing the API key, changing environments, resetting the merchant session, or testing initialization and sign-in again. It is available in **iOS SDK 1.0.21 and later** in UAT and Production. This is an explicit Koard SDK method. Calling `KoardMerchantSDK.shared.deinit()` does not destroy the Swift singleton or require your app to release it. ## Choose the right cleanup operation | Operation | What it does | What remains | |---|---|---| | `deinitializeCardReader()` | Drops the reader session, resets status to `.notReady`, and recreates the reader | SDK configuration and merchant authentication | | `logout()` | Clears merchant authentication, saved location/session data, the reader session, and its cached token | SDK initialization and its API key | | `deinit()` | Performs logout, clears the API-key provider and in-memory key, and sets initialization to `false` | The shared SDK object and device enrollment | Use `logout()` for an ordinary sign-out. Use `deinit()` when the next session must start with a fresh SDK configuration. Neither operation deletes Koard transaction records or revokes the API key on the server. ## What deinit does The SDK calls `logout()` first. Logout clears the merchant token and SDK-owned session/location storage, drops the reader session, resets reader status to `.notReady`, and invalidates the cached card-reader token. Deinit then clears the API key and marks the SDK uninitialized. The method is synchronous and does not throw. It resets SDK state; it is not a payment cancellation or refund operation. Call it after any active payment and reader preparation have finished. Unlike Android deinit, iOS deinit **does not unenroll the device or remove its enrollment key/certificate**. It also does not unlink the merchant's Apple Tap to Pay account. Check account linking and reader readiness again after signing in; preserved enrollment does not mean a new merchant is linked or ready. ## Check initialization state ```swift import KoardSDK let sdkInitialized = KoardMerchantSDK.shared.isInitialized ``` `isInitialized` is a Boolean property, not a function. It returns `false` before `initialize(options:apiKey:)` and after `deinit()`. Read it to show the SDK's configuration state on a login or settings screen. Check `isAuthenticated` for merchant sign-in and `status` for reader readiness separately. Initializing the SDK does not authenticate a merchant or prepare the card reader. ## Deinitialize and initialize again ```swift import KoardSDK @MainActor func deinitializeSdk() { KoardMerchantSDK.shared.deinit() assert(!KoardMerchantSDK.shared.isInitialized) } @MainActor func initializeSdk(apiKey: String, options: KoardOptions) { let sdk = KoardMerchantSDK.shared if !sdk.isInitialized { sdk.initialize(options: options, apiKey: apiKey) } } ``` Pass `KoardOptions(environment: .uat, loggingLevel: .error)` for UAT or `.production` for Production, together with the matching API key. Your app must supply the key again from its existing configuration or credential mechanism. Deinit clears the SDK's copy, not the app's configuration. Calling `initialize()` while already initialized leaves the current configuration in place. To replace it, complete deinit first and then initialize. Keep lifecycle actions sequential and prevent new payment/preparation work during the transition. After reinitialization, [log in and select an active location](running-payments.md), check account linking, and prepare the reader before accepting payments. There is no automatic restoration of the old merchant login after deinit. ## Verify the flow in the demo Update the [standard demo](demo.md) to SDK 1.0.21 before adding lifecycle controls. Call `deinitializeSdk()` after payment work finishes, disable payment controls, then call `initializeSdk()` with the demo's current environment and API key. Sign in, select a location, and prepare the reader again. Display initialization separately from authentication and reader readiness. The app's retained configuration permits reinitialization; it does not mean deinit kept the SDK's credentials. ## See also - [Transaction Metadata](transaction-metadata.md) - [Running Payments](running-payments.md) - [Android SDK Lifecycle](../../android-sdk/details/sdk-lifecycle.md) — Android clears enrollment during deinit # Transaction Metadata on iOS Attach your own JSON context to a sale, preauthorization, or refund with the optional `metadata` parameter. It is available in **iOS SDK 1.0.21 and later** in UAT and Production. [Install or update the SDK](installing-sdk.md) before using these examples. ## What is stored Metadata is an optional JSON object containing your application's context, such as an order ID, invoice reference, cart ID, or refund reason. Use it to reconcile Koard events with your own records and explain why a payment operation occurred. It belongs to the **individual transaction event** that the request creates. It is separate from `eventId` (idempotency), `transactionId` (the payment lifecycle), and payment settings such as amount, tax, tip, or surcharge. Putting those settings in metadata does not configure the payment. - Use string keys and JSON values: strings, numbers, booleans, nulls, arrays, or nested objects. Convert dates and custom application objects to JSON-compatible values first. - Omitting metadata stores no custom metadata for that event. An empty object `{}` is valid and is distinct from no value. - A refund can carry different metadata from the original sale. Later events do **not** automatically copy or merge the previous event's metadata. Pass the order reference again when you want it on a refund. - Metadata is stored by Koard for retrieval. It is not a processor configuration field or a guarantee of text appearing on a receipt. - Include business references only; do not include card numbers, CVVs, PINs, API keys, session tokens, or other secrets. ## Send metadata with a sale or preauth Use a Swift dictionary of type `[String: Any]` whose values can be encoded as JSON. The example assumes the SDK is initialized, the merchant is signed in, a location is selected, and the reader is ready. ```swift import KoardSDK @MainActor func saleWithMetadata(eventId: String) async throws -> TransactionResponse { let metadata: [String: Any] = [ "order_id": "order-123", "channel": "mobile", "fulfillment": ["pickup": true] ] return try await KoardMerchantSDK.shared.sale( amount: 1250, // $12.50 in minor currency units breakdown: nil, currency: CurrencyCode(currencyCode: "USD", displayName: "US Dollar"), eventId: eventId, metadata: metadata ) } ``` Use a UUID4 for `eventId` and follow the [idempotency guidance](../../payments/details/idempotency.md) for retries. Handle the returned payment status as in [Running Payments](running-payments.md) and save `response.transactionId`; a returned response alone does not mean the payment was approved. For an authorization without capture, replace `sale(...)` with `preauth(...)`; the `amount`, `breakdown`, `currency`, `eventId`, and `metadata` arguments above are the same. Run the sale or the preauth according to your payment flow. ## Send metadata with a refund ```swift import KoardSDK @MainActor func refundWithMetadata( transactionId: String, eventId: String ) async throws -> TransactionResponse { try await KoardMerchantSDK.shared.refund( transactionId: transactionId, amount: 500, // $5.00; nil requests a full refund eventId: eventId, metadata: [ "order_id": "order-123", "return_id": "return-456", "reason": "customer_return" ] ) } ``` The default refund does not request another tap. To use a card-present refund, supply `withTap: true` before `metadata`; the same dictionary is included with that refund request. ## How it reaches Koard The SDK encodes your dictionary into the payment request's `metadata` JSON field alongside the encrypted reader data. Sale/preauth use the v5 payment request; refunds carry metadata in their refund request. You do not need to construct the SDK's `AnyCodable` wrapper or encode the dictionary yourself. Omit the parameter or pass `nil` when no custom data is needed. Unsupported values such as custom class instances cause JSON encoding to fail; use strings or JSON objects for those values. Metadata does not change the Apple reader UI, requested amount, or cardholder verification. ## Retrieve and view metadata The current SDK transaction response models do not expose a typed `metadata` property. Retrieve the raw transaction JSON through your backend or an API client using an API key authorized to read that merchant's transactions. Save the returned transaction ID, then call `GET /v1/transactions/{transaction_id}`. The top-level `metadata` belongs to the current event; `history[].metadata` belongs to each earlier event. For one specific operation, call `GET /v1/transactions/event/{event_id}` using the event ID sent with that operation. For example, from your backend or a local API client with `KOARD_API_KEY` set securely: ```bash KOARD_BASE_URL="https://api.uat.koard.com" TRANSACTION_ID="your-transaction-id" curl --fail-with-body --silent --show-error \ "$KOARD_BASE_URL/v1/transactions/$TRANSACTION_ID" \ -H "x-koard-apikey: $KOARD_API_KEY" \ -H "Accept: application/json" ``` For Production, use `https://api.koard.com` and a production key. A transaction ID and key must belong to the same environment and authorized account scope. A response after a refund may contain these fields (other transaction fields omitted): ```json { "transaction_id": "your-transaction-id", "event_id": "refund-event-id", "transaction_type": "refund", "metadata": { "order_id": "order-123", "return_id": "return-456", "reason": "customer_return" }, "history": [ { "event_id": "sale-event-id", "metadata": { "order_id": "order-123", "channel": "mobile" } } ] } ``` If a later event has `metadata: null`, check the original event in `history` or use its event lookup; null at the top level does not mean the sale's metadata was deleted. Likewise, older transactions and requests that omitted metadata can legitimately return null. To display metadata inside your app, have your backend retrieve it and return the fields your UI needs. Do not assume the SDK demo receipt or MMS transaction screen displays arbitrary metadata; the raw API response is the verification path described here. ## Verify in UAT and Production 1. Send a sale or preauth with a recognizable `order_id` and retain its event ID and returned transaction ID. 2. Retrieve that event in the same environment and confirm the metadata values and JSON types. 3. If testing a refund, pass a separate `return_id` and retain the sale's `order_id` explicitly. Retrieve the refund event and the transaction history to compare them. 4. Reinitializing or deinitializing the SDK does not erase metadata already stored in Koard. Log in again before continuing SDK operations. ## See also - [SDK Lifecycle and Deinitialization](sdk-lifecycle.md) - [Running Payments](running-payments.md) - [Android Transaction Metadata](../../android-sdk/details/transaction-metadata.md) # Koard iOS Demo App Use the standard merchant demo for login, active-location selection, reader preparation, sale/preauth, tips, refunds, and receipts. These documentation samples target **iOS SDK 1.0.21**. The demo obtains `KoardSDK` through **Swift Package Manager** from the published `https://github.com/koardlabs/koard-ios.git` package. No local SDK source checkout or manually installed framework is required. ## Set up the demo 1. Clone [koardlabs/demos](https://github.com/koardlabs/demos) and open its `ios` directory: ```bash git clone https://github.com/koardlabs/demos.git cd demos/ios cp KoardMerchantSDK-Demo/Config.plist.template KoardMerchantSDK-Demo/Config.plist ``` 2. Edit `KoardMerchantSDK-Demo/Config.plist` with your SDK API key for each environment: ```xml UAT apiKeyYOUR_UAT_API_KEY PRODUCTION apiKeyYOUR_PROD_API_KEY ``` Obtain keys from the developers page in [UAT](https://app.uat.koard.com/developers) or [Production](https://app.koard.com/developers). Keep the real plist out of source control. Enter the merchant code and PIN on the app's login screen; they are not plist settings. 3. Open `ios/KoardDemo.xcodeproj` from the repository root. In the project's **Package Dependencies**, set `koard-ios` to **Exact Version 1.0.21**, then resolve packages. The public demo checkout may still pin 1.0.20; update that pin before using metadata or SDK reset examples. 4. Select **Koard Demo (UAT)** for testing. **Koard Demo (Production)** uses production credentials and can route real payments. The scheme selects the corresponding plist section for Run and Archive. ## CocoaPods alternative The published 1.0.21 tag also contains `KoardSDK.podspec`. The demo uses SPM by default; to use CocoaPods instead, remove the SPM `KoardSDK` product from the app target and its package dependency before adding this Podfile in `demos/ios`: ```ruby platform :ios, '17.0' project 'KoardDemo', 'Debug' => :debug, 'Release' => :release, 'UAT' => :debug, 'Production' => :release target 'KoardMerchantSDK-Demo' do pod 'KoardSDK', :git => 'https://github.com/koardlabs/koard-ios.git', :tag => '1.0.21' end ``` Run `pod install`, then open `KoardDemo.xcworkspace`. Use either SPM or CocoaPods for the app target so `KoardSDK` is linked once. This is a tagged Git pod; it does not require a local SDK checkout. See [Installing the SDK](installing-sdk.md) for both dependency options. ## Run the standard checkout flow Use a supported physical iPhone with the required Tap to Pay entitlement and [Sandbox Apple Account](creating-sandbox-account.md) for UAT. A simulator can display UI but cannot perform a card tap. 1. Log in with the merchant's code and PIN. 2. Open Settings and choose that merchant's active location. Its linked terminal determines payment routing. Display the choice in Settings and on the payment screen; revalidate saved choices after login or a merchant change. 3. Prepare the reader. If the account is unlinked, present an explicit action to accept the Tap to Pay terms, await `linkAccountAsync()`, then prepare again. 4. Enter the amount, select sale or preauth, and tap a test card. Persist the operation ID before calling the SDK. 5. Inspect the returned transaction. Handle partial approval, surcharge confirmation, pending, declined, and canceled outcomes before showing a receipt. Preauth authorizes a hold; capture is a separate operation. 6. Use transaction details for capture, refund, reversal, tip adjustment, and receipt delivery where supported by the merchant's processor. The [Running Payments](running-payments.md) samples follow this checkout sequence. They poll the SDK's public reader status, leave the reader event subscription to the SDK, and inspect the actual transaction returned by each operation. See [Transaction Metadata](transaction-metadata.md) and [SDK Lifecycle](sdk-lifecycle.md) for 1.0.21 features. # Installing the SDK Install the Koard Merchant SDK to enable tap-to-pay functionality in your Android application. If you're ready to start developing, see our Android SDK implementation guide. [Get started with Koard](/docs/introduction) **What you learn** In this guide, you'll learn: * How to add the Koard Merchant SDK to your Android project * How to configure Gradle dependencies * How to initialize the SDK in your application * How to install Visa Tap to Pay Ready directly from Google Play or from your app * How to verify your installation is working correctly **Prerequisites** Before you begin, ensure you have: * **Android Studio Hedgehog or newer** (required for modern Gradle support) * **JDK 21** (required for compilation) * **Android SDK 36** (minimum SDK 33 - Android 13) * **Physical Android device with NFC support** (Android 13+) — see [Supported Devices & NFC Tap Location](/docs/setting-up-the-android-sdk/supported-devices) * **Valid Koard merchant account** (configured in [Koard MMS](/docs/appendix/glossary#mms "Koard portal for merchants, terminals, locations, credentials, and API keys.")) * **Visa Tap to Pay Ready 25.06.10 or later** must be installed before enrollment or payments — choose either of the [installation options](#step-5-install-visa-tap-to-pay-ready) below **Physical Device Required**: [Tap to Pay](/docs/appendix/glossary#tap-to-pay-android "Contactless acceptance on NFC Android devices using the Visa Tap to Pay Ready kernel.") on Android requires a physical device with NFC hardware. The Android emulator does not support NFC contactless payments. **Developer Mode Workflow**: When working with Tap to Pay on Android, follow this important workflow: 1. **Enable Developer Mode** - Turn on developer mode to install and test app revisions 2. **Install/Update Your App** - Deploy your application updates 3. **Disable Developer Mode** - You MUST turn off developer mode before running tap to pay transactions 4. **Run Transactions** - Process payments with developer mode disabled Tap to Pay transactions will NOT work if developer mode is enabled. This is a security requirement from the payment processor. **Lifecycle and metadata support**: The new [SDK Lifecycle](/docs/setting-up-the-android-sdk/sdk-lifecycle) and [Transaction Metadata](/docs/setting-up-the-android-sdk/transaction-metadata) examples require SDK **1.0.7 or later**. This guide targets 1.0.7. Use Maven Central as shown below, or the supplied AAR through [Manual AAR Installation](#fallback-manual-aar-installation). The [1.0.7 demo branch](https://github.com/koardlabs/koard-android/tree/release/1.0.7/libs-maven) also includes the same signed release in its Maven mirror. ## Step 1: Add the SDK Dependency Use SDK **1.0.7**, published on **Maven Central** as `com.koard:koard-android-sdk:1.0.7` (packaging `aar`). No credentials or custom repository are required — only `mavenCentral()` in your repositories. ### 1a. Ensure Maven Central is a repository Most Android projects already resolve dependencies from Maven Central. If yours doesn't, add `mavenCentral()` to `dependencyResolutionManagement` in your **`settings.gradle.kts`**: ```kotlin dependencyResolutionManagement { repositories { google() mavenCentral() } } ``` ### 1b. Add the dependency Add the Koard SDK to your **app module's `build.gradle.kts`**: ```kotlin dependencies { implementation("com.koard:koard-android-sdk:1.0.7") } ``` ### 1c. Declare the libraries the SDK does _not_ bring in The 1.0.7 artifact is a **shaded fat AAR**. OkHttp, Okio, Retrofit, kotlinx.serialization, and Ktor are packaged _inside_ the AAR (relocated under `com.koardlabs.*`) and are **deliberately dropped from the published POM** — this isolates the SDK from whatever versions your app uses. The published POM declares only: | Dependency | Version | | ------------------------------------------------ | ------- | | `org.jetbrains.kotlin:kotlin-stdlib` | 2.2.21 | | `org.jetbrains.kotlin:kotlin-parcelize-runtime` | 2.2.21 | | `androidx.security:security-crypto` | 1.1.0 | | `com.google.android.gms:play-services-safetynet` | 18.0.1 | | `com.nimbusds:nimbus-jose-jwt` | 10.0.2 | **Nothing else resolves transitively.** AndroidX, Kotlin coroutines, and any networking or serialization library your own code uses must be declared in your app module. If you previously relied on the SDK to pull OkHttp/Retrofit/kotlinx.serialization in for you (1.0.5 and earlier shipped them as ordinary transitive dependencies), declare them directly now. ```kotlin dependencies { implementation("com.koard:koard-android-sdk:1.0.7") // Declare what YOUR code needs — the SDK no longer supplies these transitively. implementation("androidx.core:core-ktx:1.12.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") } ``` ### Fallback: Manual AAR Installation If you have been supplied the SDK as a raw `.aar` file (for example, an offline or pre-release build), you can side-load it instead of resolving from Maven Central: 1. **Create a `libs` directory** in your app module if it doesn't exist 2. **Copy the AAR file** (e.g., `koard-android-release.aar`) to the `libs/` directory 3. **Add the dependency** in your `build.gradle.kts`: ```kotlin dependencies { implementation(files("libs/koard-android-release.aar")) // A raw file dependency carries no POM, so declare everything the SDK // would otherwise pull in — plus whatever your own code needs. // The five dependencies the published POM declares: implementation("org.jetbrains.kotlin:kotlin-stdlib:2.2.21") implementation("org.jetbrains.kotlin:kotlin-parcelize-runtime:2.2.21") implementation("androidx.security:security-crypto:1.1.0") implementation("com.google.android.gms:play-services-safetynet:18.0.1") implementation("com.nimbusds:nimbus-jose-jwt:10.0.2") // Plus whatever your own code needs: implementation("androidx.core:core-ktx:1.12.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") } ``` Do **not** add OkHttp, Retrofit, Okio, kotlinx.serialization, or Ktor "for the SDK" — they are already shaded inside the AAR under `com.koardlabs.*`. Add them only if _your own_ code uses them; the shaded copies will not conflict with yours. ## Step 2: Configure AndroidManifest.xml Permissions and Package Visibility Add the required permissions, the `` tag, and the NFC intent filter to your `AndroidManifest.xml`: ```xml ``` The `` tag declares visibility to the Tap to Pay kernel app (`com.visa.kic.app.kernel`), which is required for the SDK to detect and communicate with the Visa kernel during tap-to-pay flows. Android 11 and later hide other installed apps unless they are declared this way, so without it enrollment fails with "Kernel App is not installed" even when the kernel is installed. See [Troubleshooting](/docs/setting-up-the-android-sdk/troubleshooting). **Where each permission comes from.** `INTERNET`, `ACCESS_NETWORK_STATE`, `NFC`, and `WAKE_LOCK` are declared in the SDK's own manifest and merged into your app, but declaring them explicitly keeps your permission surface auditable. `ACCESS_WIFI_STATE` and `REBOOT` are **required by Visa's Kernel-in-Cloud (KiC) integration spec** (§3.3.3, "mandatory minimum permissions") for apps that integrate the Tap to Pay Ready kernel — they are **not** contributed by the Koard SDK, so you must declare them yourself. The SDK also contributes `` and `android.hardware.nfc.hce`, both with `required="false"`. The `` tag and the NFC intent filter are likewise not contributed by the SDK. If your activity sets `android="portrait"`, also declare `` so Google Play does not filter out POS devices (Sunmi D3, iMin Swan 1 Pro, and similar) that do not report that feature. ### Add the NFC Tech Filter Resource Create `res/xml/nfc_tech_filter.xml` referenced by the `meta-data` above: ```xml android.nfc.tech.IsoDep ``` Confirm the app's merged manifest includes the `com.visa.kic.app.kernel` package query as a child of ``. This is an app manifest requirement; changing Gradle dependencies alone does not make the package visible. ## Step 3: Configure Build Settings ### Set Minimum SDK Version Ensure your app's minimum SDK is set to Android 13 (API level 33): ```kotlin android { compileSdk = 36 // belongs on `android { }`, NOT inside defaultConfig defaultConfig { minSdk = 33 targetSdk = 36 } } ``` ### Configure Java Compatibility Set Java version to 21: ```kotlin android { compileOptions { sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 } kotlinOptions { jvmTarget = "21" } } ``` ### Add Build Flavors (Optional) The SDK ships exactly two built-in environments — `KoardEnvironment.UAT` and `KoardEnvironment.PROD` — so mirror the demo app and define one flavor per environment: ```kotlin android { flavorDimensions += "environment" productFlavors { create("uat") { dimension = "environment" isDefault = true buildConfigField("String", "ENVIRONMENT", "\"UAT\"") applicationIdSuffix = ".uat" // UAT: https://api.uat.koard.com } create("prod") { dimension = "environment" buildConfigField("String", "ENVIRONMENT", "\"PROD\"") // Production: https://api.koard.com } } } ``` There is no `dev` environment constant. To target a non-standard backend, use `KoardEnvironment.Custom(koardApiUrl, enrollmentUrl, visaCloudPosUrl, name)` rather than adding a `dev` flavor that has nothing to map onto. ## Step 4: Initialize the SDK ### Create Application Class Start SDK initialization from `Application.onCreate()` on a worker scope. Expose initialization state to your UI and wait for it before retrieving the SDK or opening authenticated screens. Keep the main thread free while initialization runs. ```kotlin import android.app.Application import com.koardlabs.merchant.sdk.KoardMerchantSdk import com.koardlabs.merchant.sdk.domain.KoardEnvironment import com.koardlabs.merchant.sdk.domain.KoardLogLevel import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch class MyApplication : Application() { enum class SetupState { STARTING, READY, FAILED } private val sdkScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val _setupState = MutableStateFlow(SetupState.STARTING) val setupState = _setupState.asStateFlow() override fun onCreate() { super.onCreate() sdkScope.launch { try { KoardMerchantSdk.initialize( application = this@MyApplication, apiKey = "your-api-key", environment = KoardEnvironment.UAT, timeoutSeconds = 30L, logLevel = KoardLogLevel.NONE ) _setupState.value = SetupState.READY } catch (error: CancellationException) { throw error } catch (error: Exception) { _setupState.value = SetupState.FAILED } } } } ``` Render a setup indicator for `STARTING`, your login or authenticated screen for `READY`, and a setup error with a retry or restart action for `FAILED`. Apply the same readiness gate before registering NFC or refreshing reader checks. Cancel any foreground setup job when the activity pauses, so a delayed initialization cannot register an inactive activity. **Important**: * SDK initialization must run on a worker thread. Do not wrap it in `runBlocking` from `Application.onCreate()`, because that still blocks app startup even when the inner work uses `Dispatchers.IO`. * Retrieve `KoardMerchantSdk.getInstance()` only after initialization succeeds. * The `initialize()` arguments are `application`, `apiKey`, `environment`, and optional `timeoutSeconds` and `logLevel`. Authenticate the merchant separately with `login(merchantCode, merchantPin)`. * `logLevel` is configured at initialization. Use `NONE` for production builds. ### Register Application in Manifest Add your custom Application class to `AndroidManifest.xml`: ```xml ``` ## Step 5: Install Visa Tap to Pay Ready Visa Tap to Pay Ready **25.06.10 or later** must be installed on the merchant's device before enrollment or payments. The minimum version is **25.06.10**, correcting the `26.06.10` typo in earlier SDK requirements. Version **25.06.25** meets this minimum. Keep the app updated through Google Play. Choose either installation option: * **Install directly from Google Play:** the merchant opens the [Google Play listing](https://play.google.com/store/apps/details?id=com.visa.kic.app.kernel) and installs the app. * **Install from your app (optional):** offer an Install action using `sdk.installKernelApp(activity)`. Both options satisfy the installation requirement. Adding an in-app Install action is optional. ### Optional: In-App Installation After SDK initialization, you can provide an **Install** action that checks for the app and launches the install flow when needed: ```kotlin import android.app.Activity import com.koardlabs.merchant.sdk.KoardMerchantSdk import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext // Call from a coroutine when the merchant taps Install. suspend fun installTapToPayReady(activity: Activity) { withContext(Dispatchers.IO) { val sdk = KoardMerchantSdk.getInstance() if (!sdk.isKernelAppInstalled()) { sdk.installKernelApp(activity) } } } ``` Pass the current `Activity`. Both SDK methods must run on a worker thread, as shown above. `installKernelApp(activity)` opens the Google Play in-app install overlay when available and falls back to the app's store listing if the overlay cannot be opened. If neither can be opened, the SDK throws `KoardException`; show an error and let the merchant retry. The method returns after launching the install screen; it does not wait for installation to finish. If the merchant cancels or installation is still incomplete, let them retry. ### Confirm Installation Whichever option the merchant uses, call `refreshReadinessChecks()` on `Dispatchers.IO` from your activity's `onResume()`, as shown in the next step. This updates `readinessState` when the merchant returns from installation. Use `readinessState.value.kernelAppInstalled` to confirm the app is installed before enrollment, and `readinessState.value.isReadyForTransactions` to decide when to enable payments. ## Step 6: Register Your Activity for NFC Android dispatches `TECH_DISCOVERED` to whatever app claims it, so an unrelated NFC app can jump into the foreground the moment the customer presents a card. `registerActivityForNfc(this)` claims NFC foreground dispatch for your activity so it stays in front for the duration of the tap — including the receipt or animation screens that follow, while the card may still be in the field (KiC integration guide §3.4.14). Call it from `onResume()` and `unregisterActivityForNfc(this)` from `onPause()`. Both are suspend functions that must run on a worker thread. Visa marks both APIs **Implementation: Optional** (§3.3.15.1, §3.3.15.2) — a tap can complete without them — but skipping them leaves the tap exposed to being interrupted by another app, so Koard recommends wiring them up on every activity that can host a tap. ```kotlin import android.util.Log import android.widget.Toast import androidx.activity.ComponentActivity import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import com.koardlabs.merchant.sdk.KoardMerchantSdk import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext class MainActivity : ComponentActivity() { private val nfcMutex = Mutex() private var foregroundSetup: Job? = null override fun onResume() { super.onResume() foregroundSetup?.cancel() foregroundSetup = lifecycleScope.launch { val state = (application as MyApplication).setupState.first { it != MyApplication.SetupState.STARTING } if (state != MyApplication.SetupState.READY || !lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) return@launch try { withContext(Dispatchers.IO) { nfcMutex.withLock { KoardMerchantSdk.getInstance().registerActivityForNfc(this@MainActivity) } KoardMerchantSdk.getInstance().refreshReadinessChecks() } } catch (error: CancellationException) { throw error } catch (error: Exception) { Toast.makeText(this@MainActivity, "Tap to Pay setup failed. Reopen the screen to retry.", Toast.LENGTH_LONG).show() } } } override fun onPause() { foregroundSetup?.cancel() lifecycleScope.launch(Dispatchers.IO) { if ((application as MyApplication).setupState.value == MyApplication.SetupState.READY) { try { nfcMutex.withLock { KoardMerchantSdk.getInstance().unregisterActivityForNfc(this@MainActivity) } } catch (error: CancellationException) { throw error } catch (error: Exception) { Log.w("TapToPaySetup", "NFC foreground cleanup failed", error) } } } super.onPause() } } ``` **Wait for setup and cancel paused work**: wait for initialization before accessing the SDK, cancel the foreground job on pause, and guard NFC registration and unregistration with a mutex so rapid resume/pause cycles cannot interleave them. Handle SDK errors in your app and show the merchant a retry action. Also add the `TECH_DISCOVERED` intent filter and the `@xml/nfc_tech_filter` tech-list to the same activity in your manifest — per KiC §3.4.14 the registration APIs are meant to be used alongside that manifest declaration, and it is not contributed by the Koard SDK. `unregisterActivityForNfc()` is safe to call from `onPause()` even during a tap: the SDK detects an in-flight tap and skips the unregister. This matters because the Visa kernel foregrounds its own secure Online-PIN keypad, which pauses your activity — tearing down the registration there would abort PIN entry and decline the sale. ## Step 7: Authenticate Merchant After SDK initialization, authenticate the merchant using their credentials: ```kotlin import android.os.Bundle import androidx.activity.ComponentActivity import androidx.lifecycle.lifecycleScope import com.koardlabs.merchant.sdk.KoardMerchantSdk import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch class LoginActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) lifecycleScope.launch(Dispatchers.IO) { try { val sdk = KoardMerchantSdk.getInstance() // Login with merchant credentials val success = sdk.login( merchantCode = "your-merchant-code", merchantPin = "your-merchant-pin" ) if (success) { println("Merchant authenticated successfully") } else { println("Authentication failed") } } catch (e: CancellationException) { throw e } catch (e: Exception) { println("Error: ${e.message}") } } } } ``` The `login()` method authenticates the merchant and retrieves an access token. This must be done before processing transactions. `login()` returns `false` rather than throwing when the credentials are rejected. If your integration has already resolved the merchant identity into a single opaque string (a QR scan, an SSO callback, or a server-issued provisioning token), use the `login(alias: String): Boolean` overload instead. It hits the same login route and yields the same session token; the alias itself is never stored. Call `logout()` to end the session. Logout clears the session token and the active location but **preserves enrollment**, so the same merchant can log back in without re-enrolling. Switching the device to a _different_ merchant requires an explicit `unenrollDevice()` first. ### Test Basic Import Add this import statement to verify the SDK is accessible: ```kotlin import com.koardlabs.merchant.sdk.KoardMerchantSdk import com.koardlabs.merchant.sdk.domain.KoardEnvironment import com.koardlabs.merchant.sdk.domain.KoardLocation import com.koardlabs.merchant.sdk.domain.exception.KoardException ``` If the imports succeed without errors, your installation is working correctly. ## Step 8: Select a Location and Enroll the Device **Both of these steps are mandatory.** Enrollment is **not** automatic — nothing happens as a side effect of `login()`. Until the device has an active location _and_ is enrolled, `sale()`, `preauth()`, `completePartialAuth()`, and `refundEmv()` throw `KoardException` with `error.errorType` of `KoardErrorType.NotReady.NoActiveLocation` or `KoardErrorType.NotReady.NotEnrolled`. The order is fixed: authenticate, choose a location, then enroll. ```kotlin import com.koardlabs.merchant.sdk.KoardMerchantSdk import com.koardlabs.merchant.sdk.domain.exception.KoardException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext suspend fun setUpDevice(selectedLocationId: String) = withContext(Dispatchers.IO) { val sdk = KoardMerchantSdk.getInstance() // 1. Revalidate the operator's app-settings selection after login. val merchant = sdk.getMerchantAccount().getOrElse { error -> println("Could not load merchant: ${error.message}") return@withContext } val locations = sdk.getLocations().getOrElse { error -> println("Could not load locations: ${error.message}") return@withContext } val location = locations.firstOrNull { it.id == selectedLocationId && it.accountId == merchant.id } if (location == null || location.terminalId.isNullOrBlank()) { println("Choose a location belonging to this merchant with a linked terminal") return@withContext } try { // 2. Set the active location. Backend-authoritative: it re-binds the // terminal on the backend and only commits locally if that succeeds. sdk.setActiveLocation(location.id) // 3. Enroll the device. Returns a String status; throws on failure. val status = sdk.enrollDevice() println("Enrollment status: $status") } catch (e: KoardException) { println("Setup failed: ${e.error.shortMessage} (${e.error.errorType})") } } ``` ### `setActiveLocation(locationId)` * Suspend, worker thread only, returns `Unit` and **throws** on failure — it no longer silently no-ops. * On an unenrolled device it simply records the selection and flips readiness to `hasActiveLocation = true`. It does **not** enroll for you; you still have to call `enrollDevice()`. * If the device is **already enrolled** and you switch to a _different_ location, the SDK re-binds the terminal on the backend first and commits the new location locally only if the backend approves. A failed re-bind throws and leaves the previous location intact. It then pushes the new location's terminal/CVM profile down to the kernel. * Switching locations on an enrolled device **throws `InvalidRequest` when developer mode is on** — the Visa kernel will not accept a new profile in that state, so the SDK refuses the switch rather than half-applying it. * During the switch, readiness reports `NotReady.Preparing`, so a tap started mid-switch is rejected instead of running against a terminal that is still moving. Observe the resolved location reactively rather than caching your own copy — a private cache survives logout and goes stale, which blocks the next enrollment with "no active location set": ```kotlin sdk.activeLocation.collect { location -> // StateFlow updateUi(location?.name) } // On a cold start, resolve the persisted id into a full object once: sdk.refreshActiveLocation() // suspend, returns KoardLocation? sdk.activeLocationId // String? — the raw persisted id ``` ### `enrollDevice()` `suspend fun enrollDevice(): String` — worker thread only. It returns a status **String** and throws `KoardException` when it cannot proceed. Before calling it, the SDK refreshes its readiness checks and rejects the call when: * **Developer mode is enabled** — `InvalidRequest`, "Disable developer mode before enrolling device." * **The device is already enrolled** — `InvalidRequest`, "Clear enrollment data before enrolling again." Call `clearEnrollmentState()` (local only) or `unenrollDevice()` (clears the enrollment data held by the Tap to Pay Ready app, then clears local state) first. Note that `unenrollDevice()` does **not** fully deprovision the device on Visa's backend — see [Troubleshooting](/docs/setting-up-the-android-sdk/troubleshooting#tamperdetected-on-every-sale-after-a-failed-enrollment). * **No active location is set** — enrollment requires one, which is why Step 7 runs in this order. Because it is a user-visible, failure-prone operation, surface it as an explicit action in your UI (the demo app renders an **Enroll Device** button whenever `readinessState.enrollmentState` is not `Enrolled`) rather than firing it silently on login. Track progress through `sdk.readinessState`, whose `enrollmentState` moves `NotEnrolled` → `Enrolling` → `Enrolled` (or `Failed`). Use `readiness.notReadyReason()` to get the typed blocking reason without attempting a transaction. ## Step 9: Transaction Processing With installation complete you can now run Tap to Pay sessions. Keep these guardrails in mind: * `sdk.sale(...)`, `sdk.preauth(...)`, `sdk.completePartialAuth(...)`, `sdk.refundEmv(...)`, and `sdk.prepare()` are the APIs that drive a reader session, so they return a cold `Flow` that drives the reader UI. Other calls reach the thin client too (`isKernelAppInstalled()`, `checkKiCEligibility()`, `getTransactionConfig()`, `cancelTransaction()`, `resetKernelService()`, `unenrollDevice()`, the NFC registration pair), but they are one-shot and emit no reader events. * Reference-based `sdk.refund(transactionId, ...)` returns `Result` without a tap. The Activity-based `sdk.refund(..., withTap = true)` returns a reader Flow; `refundEmv(...)` is also supported as a tap-refund alias. * Every other payment action (capture, reverse, [incremental auth](/docs/appendix/glossary#incremental-auth "Increase an existing authorization hold by a delta amount."), receipts, tip adjustment) is a suspend function that returns a `Result` and never emits reader events. * The SDK enforces readiness (`sdk.readinessState.value.isReadyForTransactions`) and blocks taps if the required tap-to-pay dependency is missing, developer mode is on, the device is not enrolled, or no active location is set. Rather than duplicating code here, the dedicated [Running Payments](/docs/setting-up-the-android-sdk/running-payments) guide shows: * How the demo’s `MainScreenViewModel` builds `PaymentBreakdown`, collects the Flow, and updates UI state * Mapping `KoardReaderStatus` + reader status codes to your own UX * Handling [surcharge](/docs/appendix/glossary#surcharge "Fee on eligible credit-card transactions to offset processing costs (not debit).") confirmation, canceling the reader, and orchestrating post-reader calls (capture/refund/reverse/incremental/[tip adjust](/docs/appendix/glossary#tip-adjust "Update gratuity before settlement; does not recompute surcharge.")/receipt) Use that guide as the canonical reference when wiring payments into your application. ## Minification, ProGuard, and R8 SDK 1.0.7 includes the consumer rules required for minified release builds. Keep R8 enabled if your app uses it; Gradle merges the AAR's rules automatically. The fix preserves Retrofit coroutine/response generic signatures and relocates the bundled dependencies' rules to their `com.koardlabs.*` package names. On 1.0.6, missing rules could cause `java.lang.Class cannot be cast to java.lang.reflect.ParameterizedType` during login before an HTTP request was sent. Upgrade to 1.0.7 and clean-rebuild your app and any Flutter/React Native/JVM wrapper that calls the SDK. Kotlin source callers can omit optional metadata, but Java callers must pass the new argument (`null` when unused); previously compiled payment call sites must be rebuilt. Do not add keep rules for unshaded `retrofit2.*` or `okhttp3.*` to fix the SDK's shaded copies. Avoid repackaging the AAR in a way that drops its consumer rules or embedded `META-INF` rule resources. SDK logging can remain `KoardLogLevel.NONE`; logging verbosity is not a substitute for correct R8 rules. Validate the actual minified release build on a supported physical device. For a remaining failure, preserve the full native exception/cause chain and the matching R8 `mapping.txt`, without logging merchant PINs, API keys, or session tokens. ## Troubleshooting ### Common Installation Issues **SDK Not Initialized Error** ```plaintext IllegalStateException: Instance is null. Did you forget to call initialize? ``` **Solution**: Ensure `KoardMerchantSdk.initialize()` is called in `Application.onCreate()` on a worker thread before accessing `getInstance()`. **Thread Enforcement Error** **Solution**: All SDK operations must be called from a worker thread. Use `Dispatchers.IO`: ```kotlin lifecycleScope.launch(Dispatchers.IO) { // SDK operations here } ``` **Dependency Conflicts** **Solution**: Ensure you're using compatible versions of AndroidX and Kotlin libraries. Check for dependency conflicts with: ```bash ./gradlew app:dependencies ``` **Build Errors** **Solution**: * Verify you're using JDK 21 * Ensure minimum SDK is set to 33 (Android 13) * Clean and rebuild: `./gradlew clean build` **NFC Transactions Failing** **Solution**: * **Disable developer mode before running transactions** - This is required for security * Developer mode blocks tap to pay functionality * Workflow: Enable dev mode → Install app → Disable dev mode → Run transactions * Go to **Settings > System > Developer Options** and toggle OFF * Restart device after disabling developer mode ### Verification Checklist * [ ] Android Studio Hedgehog or newer installed * [ ] JDK 21 configured * [ ] Minimum SDK set to 33 (Android 13) * [ ] `mavenCentral()` present in repositories * [ ] SDK dependency added to build.gradle.kts * [ ] Application class created and registered * [ ] SDK.initialize() called on worker thread * [ ] Required permissions added to manifest (INTERNET, ACCESS\_NETWORK\_STATE, ACCESS\_WIFI\_STATE, REBOOT, NFC, WAKE\_LOCK) * [ ] `` tag added with `com.visa.kic.app.kernel` * [ ] NFC `TECH_DISCOVERED` intent filter and `@xml/nfc_tech_filter` meta-data added to your tap activity * [ ] `registerActivityForNfc()` / `unregisterActivityForNfc()` wired into `onResume()` / `onPause()` * [ ] `setActiveLocation(locationId)` called after login * [ ] `enrollDevice()` exposed as an explicit user action and completed successfully * [ ] Project builds without errors * [ ] Import statements work correctly * [ ] Visa Tap to Pay Ready 25.06.10 or later installed directly from Google Play or through the optional in-app flow * [ ] `refreshReadinessChecks()` runs on resume, and `readinessState.value.kernelAppInstalled` is `true` before enrollment * [ ] Physical device with NFC available for testing * [ ] Aware of developer mode workflow (enable → install → disable → run transactions) ## Next Steps Once the SDK is installed and configured: * [Run the Demo App](/docs/setting-up-the-android-sdk/demo) - Test SDK functionality with the demo application * [Supported Devices & NFC Tap Location](/docs/setting-up-the-android-sdk/supported-devices) - Compatible devices, requirements, and where to tap * [Running Payments](/docs/setting-up-the-android-sdk/running-payments) - Implement tap-to-pay flows, surcharging, and post-reader actions * [SDK Response Codes](/docs/setting-up-the-android-sdk/sdk-response-codes) - Understand transaction outcomes, display messages, and error handling * [Troubleshooting](/docs/setting-up-the-android-sdk/troubleshooting) - Fix enrollment failures and taps that cancel instantly * [Understand Payment Lifecycle](/docs/payments/payment-lifecycle) - Learn about the complete payment flow ## See also This wraps up the SDK installation. See the links below for next steps in your integration: * [Demo App Setup](/docs/setting-up-the-android-sdk/demo) - Run the demo application * [SDK Response Codes](/docs/setting-up-the-android-sdk/sdk-response-codes) - Transaction outcomes, error codes, and display messages * [Payment Lifecycle](/docs/payments/payment-lifecycle) - Complete payment flow guide * [Supported Devices & NFC Tap Location](/docs/setting-up-the-android-sdk/supported-devices) - Device compatibility and tap guidance * [Troubleshooting](/docs/setting-up-the-android-sdk/troubleshooting) - Device-side fixes for enrollment and tap failures ## Best Practices * **[Idempotency](/docs/appendix/glossary#idempotency "Safe retry pattern using event_id so network failures do not double-charge.")**: Persist a unique `eventId` (UUID4) for each operation that exposes the argument. Android 1.0.7 capture and adjustment do not; reconcile uncertain outcomes before any further attempt. * **Threading**: Always hop to `Dispatchers.IO` before calling any SDK method; return results to the main thread only after the call completes. * **Environment Flavors**: Mirror the demo’s `uat`/`prod` flavors so each build points at the correct Koard environment and credential set. Use `KoardEnvironment.Custom(...)` for anything else. * **Logging**: The SDK writes to Logcat under the fixed tag **`KoardSDK`**. Set verbosity once via `initialize(..., logLevel = KoardLogLevel.VERBOSE)` — there is no runtime setter, and the SDK no longer depends on Timber. Secrets ([API key](/docs/appendix/glossary#api-key "Secret header credential for REST calls; v5 keys use scoped permissions.") and token headers, device certificates, enrollment key material) are never logged at any level. * **Device Re-provisioning**: When wiping devices or reinstalling the tap-to-pay kernel app, call `refreshDeviceCertificates()` to regenerate keys and rerun enrollment. # Running Payments on Android Learn how to drive live [Tap to Pay](/docs/appendix/glossary#tap-to-pay-android "Contactless acceptance on NFC Android devices using the Visa Tap to Pay Ready kernel.") on Android sessions with the Koard SDK and how to handle every follow-up transaction step. **What you learn** * Which APIs emit reader events, including the card-present `refund()` overload * How to validate SDK readiness before launching the thin client * Handling `Flow` to power custom UIs * Running Practice Mode taps to validate the online-PIN flow * Managing [surcharge](/docs/appendix/glossary#surcharge "Fee on eligible credit-card transactions to offset processing costs (not debit).") confirmation, tip/tax/surcharge breakdowns, and telemetry * Post-reader operations such as capture, refund, EMV refund, reverse, [incremental auth](/docs/appendix/glossary#incremental-auth "Increase an existing authorization hold by a delta amount."), and tip adjustments For custom order/refund references, see [Transaction Metadata](/docs/setting-up-the-android-sdk/transaction-metadata). For `deinit()`, initialization status, and signing in after a reset, see [SDK Lifecycle and Deinitialization](/docs/setting-up-the-android-sdk/sdk-lifecycle). These examples target the published **Android SDK 1.0.7**. Amount arguments are `Int` minor currency units. [Install this version](/docs/setting-up-the-android-sdk/installing-the-sdk) before using them. ## Active Location and Terminal Routing **Your app must provide a way to select the active location in settings.** Authenticate the merchant, load that merchant's available locations, and let the operator select the location where the device is taking payments. The selected `location_id` resolves the linked `terminal_id`, whose processor configuration, MID, and TID determine payment routing for that merchant. Show the active location in settings and on the payment screen. Select it before reader preparation or any payment. If the operator changes merchants, clear the previous selection and reload the new merchant's locations. If you remember a choice between sessions, validate that it still belongs to the authenticated merchant and still has a usable linked terminal. Do not silently reuse another merchant's location or select an arbitrary terminal. * **iOS:** call `KoardMerchantSDK.shared.setActiveLocationID(location.id)`, then follow the account-linking and reader preparation flow. * **Android:** call the suspending `sdk.setActiveLocation(location.id)`, then `sdk.enrollDevice()` and the reader preparation flow. Block payment initiation until location selection and required readiness checks have completed. If a location's terminal changes, reload the configuration and complete the SDK's readiness flow before taking another payment. ## Step 1: Check Visa Tap to Pay Ready Installation Before enrollment or payments, call `sdk.refreshReadinessChecks()` on a worker thread and inspect `sdk.readinessState.value.kernelAppInstalled`. If it is `false`, the merchant can install directly from Google Play, or your app can optionally offer an Install action using `sdk.installKernelApp(activity)`. Refresh again in `onResume()` when the merchant returns. See [Install Visa Tap to Pay Ready](/docs/setting-up-the-android-sdk/installing-the-sdk#step-5-install-visa-tap-to-pay-ready) for both options and the activity example. ## Prerequisites Before starting any NFC session: * Initialize the SDK in `Application.onCreate()` and authenticate the merchant * Call `setActiveLocation(locationId)` and then `enrollDevice()` — both are mandatory, and neither happens automatically. See [Installing the SDK](/docs/setting-up-the-android-sdk/installing-the-sdk) * Register the hosting activity for NFC in `onResume()` / `onPause()` * Disable developer mode on the device (reader emits `developerModeEnabled` if it’s left on) * Observe `KoardMerchantSdk.getInstance().readinessState` and block the UI unless `isReadyForTransactions` is `true` ```kotlin val sdk = KoardMerchantSdk.getInstance() val readiness = sdk.readinessState.value if (!readiness.isReadyForTransactions) { Log.w("Checkout", "SDK not ready: ${readiness.getStatusMessage()}") return } ``` ## Launching a Sale Session `sdk.sale`, `sdk.preauth`, `sdk.completePartialAuth`, and the Activity-based `sdk.refund(..., withTap = true)` (also available as `sdk.refundEmv`) drive a reader session, so they return cold `Flow` streams. `sdk.prepare` also returns a `Flow`, but it is a warm-up that emits progress toward `Done` — it never runs a transaction. Create the flow on a worker thread and apply `flowOn(Dispatchers.IO)` so its reader work also runs there. Collect from your UI coroutine to update the screen on Main. (Other calls reach the thin client too — `isKernelAppInstalled`, `checkKiCEligibility`, `getTransactionConfig`, `cancelTransaction`, `resetKernelService`, `unenrollDevice`, and the NFC registration pair — but they are one-shot and do not emit reader events.) ```kotlin suspend fun sale( activity: Activity, amount: Int, // amount in cents breakdown: PaymentBreakdown? = null, buttonProperties: List? = null, currency: String = "USD", eventId: String? = null, tapTimeoutMs: Long? = null, // auto-cancel the tap after this many ms transactionType: String = "Payment", // "TestWithPin" for Practice Mode metadata: JSONObject? = null // app-defined JSON context ): Flow ``` `metadata` is an optional `org.json.JSONObject`; see [Transaction Metadata](/docs/setting-up-the-android-sdk/transaction-metadata) for examples and how to retrieve it. Pass `buttonProperties` to customize the on-screen reader buttons, and `tapTimeoutMs` to automatically cancel the reader session if no card is presented in time. `currency` must be one of the currency codes the kernel reports for this device. Per KiC §3.4.8 the transaction's `currencyCode` "can be one of the currencies that comes out of the `getTransactionConfig` api", so if you support anything beyond a single fixed currency, read the live list from `sdk.getTransactionConfig()` (a suspend call returning `KoardTransactionConfig?`, wrapping KiC §3.4.16) rather than hard-coding it. The same object carries the supported `transactionTypes`, `supportedButtons`, `supportedCountries`, and `supportedLanguages`. Visa marks `getTransactionConfig` **Implementation: Conditional** — it is there to be called on demand when you need the latest config. **`preauth` no longer matches `sale`'s signature.** `transactionType` exists on `sale()` only. In 1.0.7, both methods accept the first seven parameters (`activity` through `tapTimeoutMs`) and optional trailing `metadata`; `sale()` places `transactionType` before `metadata`. Amounts remain `Int` values in minor currency units. **Readiness is enforced by throwing, not by emitting.** `sale()`, `preauth()`, `completePartialAuth()`, and `refundEmv()` refresh their readiness checks and **throw `KoardException`** before returning a Flow when the SDK is not ready. Inspect `e.error.errorType`: it is a `KoardErrorType.NotReady.*` value such as `KernelAppNotInstalled`, `DeveloperModeEnabled`, `NotAuthenticated`, `NotEnrolled`, `NoActiveLocation`, `ReaderNotStarted`, or `Preparing`. Retry on `Preparing`; prompt the operator on the rest. Wrap the call in `try/catch`, not just the collection. ### Practice Mode (`TestWithPin`) `transactionType` is the KiC transaction type sent to the kernel: | Value | Behavior | | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `"Payment"` (default) | A real sale. The kernel picks the CVM from the card and [terminal profile](/docs/appendix/glossary#terminal-profile "Reusable terminal settings for tipping, surcharge, receipts, timeouts, and UI.") — usually signature or no-CVM, never a guaranteed PIN | | `"TestWithPin"` | **Practice Mode** (KiC Annex A-2). Forces the PIN-entry UI so you can validate the online-PIN flow without routing to the acquirer | ```kotlin sdk.sale( activity = activity, amount = 1000, transactionType = "TestWithPin" // Practice Mode — exercises the PIN keypad ).collect { response -> handleTransactionEvent(response) } ``` Practice Mode is only valid when the device enrollment's supported transaction types include `TestWithPin`. Read the enrollment's supported types from `sdk.getEnrollmentDetails()?.transactionTypes` before offering it in your UI. ```kotlin import android.app.Activity import com.koardlabs.merchant.sdk.KoardMerchantSdk import com.koardlabs.merchant.sdk.domain.* import com.koardlabs.merchant.sdk.domain.exception.KoardException import kotlinx.coroutines.* import kotlinx.coroutines.flow.* fun startSale(activity: Activity, amountInCents: Int, eventId: String) { // lifecycleScope runs on Main. eventId was saved with the order before this call. lifecycleScope.launch { var receivedTerminalEvent = false try { val flow = withContext(Dispatchers.IO) { KoardMerchantSdk.getInstance().sale( activity = activity, amount = amountInCents, breakdown = PaymentBreakdown(subtotal = amountInCents), currency = "USD", eventId = eventId ) }.flowOn(Dispatchers.IO) flow.collect { response -> if (handleTransactionEvent(response)) receivedTerminalEvent = true } if (!receivedTerminalEvent) { showUnresolved("Reader stopped without a final result; reconcile the saved operation") } } catch (e: CancellationException) { throw e // Preserve coroutine cancellation during navigation. } catch (e: KoardException) { // Preparing is a readiness rejection before the SDK returns a Flow. // Only retry that pre-flight case; never replay a submitted charge on a timeout. when (e.error.errorType) { is KoardErrorType.NotReady.Preparing -> scheduleRetry { startSale(activity, amountInCents, eventId) } else -> showError(message = e.error.shortMessage, errorType = e.error.errorType) } } catch (e: Exception) { showUnresolved("Payment outcome unknown; reconcile the saved operation") } } } // true means this reader session delivered a terminal event, not that payment succeeded. private fun handleTransactionEvent(response: KoardTransactionResponse): Boolean { when (response.actionStatus) { KoardTransactionActionStatus.OnProgress -> { showStatus(response.readerStatus.toString(), response.displayMessage) return false } KoardTransactionActionStatus.OnFailure -> { val canceled = response.finalStatus == KoardTransactionFinalStatus.Abort || response.readerStatus == KoardReaderStatus.readCancelled val retryableRead = response.statusCode == null || response.statusCode in setOf(12, 42, 53) if (!canceled && retryableRead && response.finalStatus == null) { // Keep collecting the SAME flow; this asks for another tap, not a new sale. showStatus(response.readerStatus.toString(), response.displayMessage ?: "Please tap again") return false } if (canceled) showAborted("Transaction canceled") else showError(code = response.statusCode, message = response.displayMessage ?: "Transaction failed", finalStatus = response.finalStatus) response.transaction?.let { reconcileTransaction(it) } } KoardTransactionActionStatus.OnComplete -> { val transaction = response.transaction when { response.finalStatus == KoardTransactionFinalStatus.Abort || transaction?.status == KoardTransactionStatus.CANCELED -> showAborted(response.displayMessage ?: "Transaction canceled") response.finalStatus == KoardTransactionFinalStatus.Decline || transaction?.status == KoardTransactionStatus.DECLINED -> showDeclined(response.displayMessage ?: "Transaction declined") response.finalStatus == KoardTransactionFinalStatus.Failure || transaction?.status == KoardTransactionStatus.ERROR -> showUnresolved(response.displayMessage ?: "Transaction failed; check its outcome") transaction == null -> showUnresolved("Missing transaction result") transaction.status == KoardTransactionStatus.SURCHARGE_PENDING -> requestSurchargeConfirmation(transaction) transaction.statusReason == "partial_approval" -> requestPartialApproval(transaction) transaction.status == KoardTransactionStatus.PENDING -> reconcileTransaction(transaction) transaction.status in setOf(KoardTransactionStatus.AUTHORIZED, KoardTransactionStatus.CAPTURED, KoardTransactionStatus.SETTLED, KoardTransactionStatus.REFUNDED, KoardTransactionStatus.REVERSED) -> showReceipt(transaction) else -> reconcileTransaction(transaction) } } null -> showUnresolved("Missing action status; reconcile the outcome") } return true } ``` The UI helpers above are application callbacks. Classify the returned transaction even when `finalStatus` is null; the standard demo also uses transaction status to handle backend-operation results. An authorized preauth is a hold, so label its receipt accordingly. `requestSurchargeConfirmation` must disclose the returned surcharge and final total, submit the cardholder decision, and inspect the returned transaction. `requestPartialApproval` offers a decision on the partial amount or a second-card payment for the remainder. Pending and unresolved outcomes retain the saved IDs for [outcome recovery](/docs/payments/idempotency), without automatically resubmitting. Persist a separate ID for each operation that exposes `eventId`. ### Event payloads Every emitted `KoardTransactionResponse` contains the data you need to build a bespoke reader UI: * **`readerStatus`** – a `KoardReaderStatus` enum value: `preparing`, `readyForTap`, `cardDetected`, `pinEntryRequested`, `pinEntryCompleted`, `readCompleted`, `readNotCompleted`, `readCancelled`, `readRetry`, `processing`, `complete`, `developerModeEnabled`, or `unknown` * **`displayMessage`** – Reader-provided instructions ("Present card", "Processing", etc.) * **`statusCode`** / **`statusCodeDescription`** – numeric status (and its description) for advanced troubleshooting * **`actionStatus`** – `OnProgress`, `OnFailure`, or `OnComplete` (these are the only three action statuses) * **`finalStatus`** – `Approve`, `Decline`, `Abort`, `Failure`, `AltService`, or `Unknown` after reader completion * **`transaction`** – populated on completion; includes IDs, surcharge state, and breakdown so you can print receipts or prompt for confirmation **Surcharge confirmation is signaled on the transaction, not the action status.** There is no `OnConfirmSurcharge` action status. When the completion event carries a transaction whose `transaction.status == KoardTransactionStatus.SURCHARGE_PENDING`, prompt the customer and call `sdk.confirm(transactionId, confirm = true/false)` with their decision. For a complete reference of every `finalStatus`, `statusCode`, display message ID, and error scenario the SDK can return, see the [SDK Response Codes](/docs/setting-up-the-android-sdk/sdk-response-codes) guide. The demo’s `MainScreenViewModel` (see `src/main/java/com/koard/android/ui/MainScreenViewModel.kt`) shows a complete Compose-based implementation that you can adapt for your UI stack. ### Preauthorization Call `sdk.preauth(...)` when you need a hold + later capture. It takes the same parameters as `sale` except `transactionType` (Practice Mode is sale-only), the Flow emits the same events, and `response.transaction?.transactionId` is the ID you’ll pass into capture/refund APIs later. ### Completing a partial approval When an authorization is only partially approved (the issuer approved less than the requested amount), call `sdk.completePartialAuth(...)` to authorize the remaining amount on another card. The customer must tap that card in a new reader session. It returns a `Flow` just like `sale`/`preauth`; pass the original transaction ID and the remaining amount, with a separately persisted operation ID: ```kotlin suspend fun completePartialAuth( activity: Activity, transactionId: String, amount: Int, breakdown: PaymentBreakdown? = null, buttonProperties: List? = null, currency: String = "USD", eventId: String? = null, tapTimeoutMs: Long? = null ): Flow ``` ### Warming up the reader `sdk.prepare()` is a **latency optimization, not a precondition for tapping.** Visa marks the pre-transaction warm-up API **Implementation: Optional** (KiC integration guide §3.4.7, "Preparing the SDK for Point-of-Sale Transactions"), and `startTransaction` (§3.4.11) lists no warm-up among its preconditions. What warming up buys you is timing: §3.4.7 describes the session-establishment and device-attestation work as happening "before startTransaction" either way, and frames the API as a fix for "the delay seen if the startTransaction is called for the first time after app restart". Calling it moves that cost off the critical user path. The one precondition §3.4.7 does state is that **the device must already be enrolled** before you warm up, so call `prepare()` after `enrollDevice()` has succeeded, not before. Call it early (for example in `Application.onCreate`, once enrollment is confirmed). It returns a `Flow` that emits progress (`Called` → `AuthenticationInProgress` → `AttestationInProgress` → `GettingConfigurations` → `ParsingConfigurations` → `Done`); key off the `Done` emission and treat any error variant as "retry on next user action". Nothing else in your flow needs to block on it — a fire-and-forget collect on a worker scope is a valid usage pattern, since the only effect is the warm-up. ```kotlin // collect() is a suspend function — call it from a coroutine on a worker scope. applicationScope.launch(Dispatchers.IO) { sdk.prepare().collect { response -> if (response.status is KoardPrepareStatus.Done) { Log.i("Checkout", "Reader is warmed up") } } } ``` ## Post-Reader Operations All backend-only operations are suspend functions that return a `Result`, so they never emit reader events and can run from any worker thread. The payload varies by call: `capture`, `reverse`, `refund`, `adjust`, and `getTransaction` return `Result`; `getTransactions` returns `Result` (which carries the paging fields); and `sendReceipt` returns `Result`. `cancelTransaction()` and `resetKernelService()` also return a `Result` (`Result`), but they are **not** backend calls — they talk to the Tap to Pay Ready app over IPC to abort a tap or release the service connection. See [Canceling the reader session](#canceling-the-reader-session). ### Capture ```kotlin suspend fun captureTransaction(transactionId: String, amountOverride: Int? = null): KoardTransaction = withContext(Dispatchers.IO) { KoardMerchantSdk.getInstance().capture(transactionId = transactionId, amount = amountOverride) .getOrThrow() } ``` ### Incremental authorization ```kotlin suspend fun incrementalAuth(transactionId: String, additionalAmountCents: Int, eventId: String): KoardTransaction = withContext(Dispatchers.IO) { KoardMerchantSdk.getInstance().incrementalAuth(transactionId, additionalAmountCents, eventId) .getOrThrow() } ``` ### Reverse / void ```kotlin suspend fun reverseTransaction(transactionId: String, eventId: String, amountCents: Int? = null): KoardTransaction = withContext(Dispatchers.IO) { KoardMerchantSdk.getInstance().reverse(transactionId, amountCents, eventId).getOrThrow() } ``` ### Refund (reference-based) The overload without an Activity returns `Result` and uses the original transaction reference, with no new tap: ```kotlin suspend fun refundTransaction(transactionId: String, eventId: String, amount: Int? = null): KoardTransaction = withContext(Dispatchers.IO) { KoardMerchantSdk.getInstance().refund( transactionId = transactionId, amount = amount, // null requests a full refund eventId = eventId, metadata = org.json.JSONObject().put("reason", "customer_return") ).getOrThrow() } ``` `sdk.refundTransaction(transactionId, amount, eventId)` is an older reference-based alias. It has no metadata argument; prefer `refund(...)` in new code. ### EMV refund (tap required) Use the Activity-based `refund(..., withTap = true)` overload, as in the standard checkout demo. This returns a `Flow` and requires a refund amount. `refundEmv(...)` is also supported in 1.0.7 as a tap-refund alias. ```kotlin suspend fun refundWithTap(activity: Activity, transactionId: String, amountInCents: Int, eventId: String) { // Invoke from a Main/UI coroutine. Handle thrown readiness errors as in startSale. val flow = withContext(Dispatchers.IO) { KoardMerchantSdk.getInstance().refund( activity = activity, transactionId = transactionId, amount = amountInCents, withTap = true, currency = "USD", eventId = eventId, metadata = org.json.JSONObject().put("reason", "customer_return") ) }.flowOn(Dispatchers.IO) var receivedTerminalEvent = false flow.collect { response -> if (handleTransactionEvent(response)) receivedTerminalEvent = true } if (!receivedTerminalEvent) showUnresolved("Refund outcome unknown; reconcile the saved operation") } ``` The tap overload and `refundEmv()` throw typed readiness errors before returning a Flow and have no `tapTimeoutMs` argument. Both accept optional `metadata`. ### Tip adjustments ```kotlin suspend fun adjustTip(transactionId: String, newTipCents: Int): KoardTransaction = withContext(Dispatchers.IO) { KoardMerchantSdk.getInstance().adjust( transactionId = transactionId, type = AmountType.FIXED, amount = newTipCents ).getOrThrow() } ``` ### Confirming a surcharge For a transaction whose status is `SURCHARGE_PENDING`, pass the cardholder's decision to this backend operation. Use `accepted = false` to reject the surcharge. `cancelTransaction()` and `resetKernelService()` only operate on the reader connection and do not replace this call. See [pending amounts and expiry](/docs/payments/surcharging#pending-amounts-rejection-and-expiry) for the 120-second confirmation window and cancellation outcomes. ```kotlin suspend fun confirmSurcharge(transactionId: String, accepted: Boolean, eventId: String): KoardTransaction = withContext(Dispatchers.IO) { KoardMerchantSdk.getInstance().confirm( transactionId = transactionId, confirm = accepted, eventId = eventId ).getOrThrow() } ``` These helpers unwrap `Result` with `getOrThrow()` and also propagate SDK exceptions. Catch both at the UI operation boundary, preserving `CancellationException`. Inspect the returned transaction status and amounts before showing success. Pending and partial outcomes need their own handling. SDK 1.0.7 does **not** expose `eventId` on `capture` or `adjust`; reconcile the transaction before any further attempt rather than treating those operations as safely replayable. ### Canceling the reader session There are two teardown paths. They do different things — one aborts a tap in flight, the other releases the IPC connection to the Tap to Pay Ready app. | Call | Use for | Effect | | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `sdk.cancelTransaction()` | Programmatic cancel, tap timeouts | Calls KiC `cancelTransaction()`, which aborts the current tap and emits a cancel terminal through the active flow, then clears the SDK's transaction guard. It does **not** call `unbindKernelService()`, so the SDK-connector's binding to the Tap to Pay Ready app is left in place | | `sdk.resetKernelService()` | Navigating away from the transaction screen (including after a completed transaction, before the receipt screen), and recovery: the kernel app died, an unrecoverable error, or "another tap is already active" | Unbinds the IPC connection to the Tap to Pay Ready app and clears the SDK's transaction state. Enrollment and the kernel app itself are untouched. The next tap re-initializes lazily; call `prepare()` afterward only to avoid paying that latency on the critical path | ```kotlin suspend fun cancelTap() { withContext(Dispatchers.IO) { val sdk = KoardMerchantSdk.getInstance() sdk.cancelTransaction() // abort the tap; leaves the IPC binding in place .onSuccess { println("Tap cancelled") } .onFailure { println("Cancel failed: ${it.message}") } } } suspend fun releaseReaderSession() { withContext(Dispatchers.IO) { val sdk = KoardMerchantSdk.getInstance() sdk.resetKernelService() // unbind IPC; safe after a completed transaction .onSuccess { println("Reader session reset") } .onFailure { println("Reset failed: ${it.message}") } } } ``` **The underlying KiC `cancelTransaction()` API is deprecated.** Visa's 25.06.10 release notes list "Deprecated cancelTransaction() Api" alongside the change that makes the Tap to Pay Ready app call cancel itself when the user presses its cancel button (it now emits `CancelTransactionInitiated` instead of asking your app to cancel). The KiC integration guide has no §3.4 section specifying `cancelTransaction()`'s behavior; it appears only in §3.4.10 as one of the calls that returns a `TransactionResponseMessage`.\ \ Koard's `cancelTransaction()` still calls it, and it is the path the SDK's own tap-timeout timer uses. Treat customer-pressed cancel as handled by the Tap to Pay Ready app, and reach for this wrapper when _your_ code needs to abort a tap. If a future kernel release removes the API, this wrapper is the surface that breaks — pin your integration tests to it. `resetKernelService()` wraps KiC `unbindKernelService()` (KiC integration guide §3.4.12, "Resetting the Service Connection State", implementation _optional_). Visa's own sample calls it once a transaction completes, before navigating to the receipt screen — so it is routine lifecycle hygiene, not just an emergency lever. It resets the connection between the SDK-connector and the Tap to Pay Ready app; it does **not** unenroll the device, uninstall the kernel app, or discard credentials.\ \ It always clears the SDK's internal state, even when the underlying unbind throws (for example because the kernel app is already dead) — the returned `Result` reports the unbind failure, but the next tap flow can still start. Use `sdk.hasActiveTapTransaction()` to check whether a tap is in flight before tearing anything down. ### Account, location, and terminal details Use these suspend lookups to read merchant, location, and terminal configuration (including surcharge and tax details) when building your checkout UI: ```kotlin sdk.getMerchantAccount() // Result sdk.getLocation(locationId) // Result sdk.getTerminal(terminalId) // Result ``` ### Transaction history and receipts ```kotlin // Offset paging over the active location's transactions. KoardTransactionDetails // carries limit/offset/page/total, so advance offset and compare offset + loaded // against total to implement "load more". sdk.getTransactions(limit = 50, offset = 0) // Result sdk.getTransaction(transactionId) // Result // Email, SMS, or both — pass null for the channel you don't want. sdk.sendReceipt( transactionId = transactionId, email = "customer@example.com", phoneNumber = null ) // Result ``` ## Next steps * Review the [Installing the SDK](/docs/setting-up-the-android-sdk/installing-the-sdk) guide for initialization, location selection, and enrollment * Explore the [Demo App](/docs/setting-up-the-android-sdk/demo) to see `MainScreenViewModel` in action * See [SDK Response Codes](/docs/setting-up-the-android-sdk/sdk-response-codes) for the full reference of final statuses, display messages, and error scenarios * Consult `KoardPaymentModels.kt` for `PaymentBreakdown`, `Surcharge`, and other metadata helpers * Check [Supported Devices & NFC Tap Location](/docs/setting-up-the-android-sdk/supported-devices) for device compatibility and where customers should tap ## Best Practices * **[Idempotency](/docs/appendix/glossary#idempotency "Safe retry pattern using event_id so network failures do not double-charge.") everywhere**: Persist a stable `eventId` for each operation that exposes it. Android 1.0.7 capture and tip adjustment have no such argument; reconcile uncertain outcomes before any further attempt. * **UI threading**: Create SDK flows on `Dispatchers.IO` and use `flowOn(Dispatchers.IO)` for their work; collect on Main for UI updates. * **Surface readiness early**: Bind `readinessState` to visible UI (like the demo Settings screen) so operators see why the reader is blocked (developer mode, missing tap-to-pay dependency, no active location). * **Telemetry hooks**: Each `KoardTransactionResponse` carries `statusCode`, `readerStatus`, and `finalStatus`. Log or export these fields for device fleet monitoring and support. * **Lifecycle hygiene**: Cancel the Flow when the hosting Activity/Fragment stops, and call `sdk.cancelTransaction()` for programmatic cancels and timeouts — it aborts the tap without unbinding. Call `sdk.resetKernelService()` when navigating away from the transaction screen — including after a completed transaction, per KiC §3.4.12 — to release the IPC connection. * **NFC registration**: Keep `registerActivityForNfc()` / `unregisterActivityForNfc()` wired into `onResume()` / `onPause()` on every activity that can host a tap. Visa marks these **optional** (KiC §3.3.15.1–2), but they claim NFC foreground dispatch so another `TECH_DISCOVERED` app cannot jump in front of your tap (§3.4.14). # SDK Lifecycle and Deinitialization `deinit()` ends the SDK's current setup so your app can initialize it again without restarting. Use it when replacing the API key, changing environments, switching to a different merchant on the device, or testing a complete reset. These examples require **Android SDK 1.0.7 or later** and apply to UAT and Production. ## Choose the right cleanup operation | Operation | What it does | What you do next | |---|---|---| | `logout()` | Clears the merchant session and active location; preserves SDK initialization and device enrollment | Log in again and select a location | | `unenrollDevice()` | Attempts Visa unenrollment and clears local enrollment certificates, keys, device identifiers, and enrollment state; keeps the SDK initialized | Enroll again before accepting taps | | `deinit()` | Unenrolls, logs out, releases the reader service, clears the SDK's API key, and removes the SDK instance | Initialize again, log in, select a location, and enroll | Use `logout()` for an ordinary sign-out when the device will continue with the same merchant. Use `deinit()` for a complete reset. For canceling a tap or releasing only the reader connection, see [Running Payments](running-payments.md#canceling-the-reader-session). ## What deinit does The SDK runs these cleanup steps in order: 1. Calls `unenrollDevice()` before logging out. It attempts to unenroll with Visa and clears local enrollment state, including device keys, certificates, the Visa device ID, and saved enrollment details. Local enrollment state is cleared even if Visa unenrollment fails. 2. Calls `logout()` to clear the merchant session and active location. 3. Resets the connection to the Visa reader service. 4. Clears the API-key provider and saved key fingerprint, removes the singleton instance, and cancels its internal coroutine scope. Cleanup is best effort: a Visa unenrollment failure does not prevent the remaining cleanup. A completed local reset is not confirmation that remote unenrollment succeeded. Deinitialization does not revoke your API key, delete the merchant or transaction records in Koard, or uninstall Visa Tap to Pay Ready. ## Check initialization state ```kotlin import com.koardlabs.merchant.sdk.KoardMerchantSdk val sdkInitialized = KoardMerchantSdk.isInitialized() ``` This static check is safe before initialization and from any thread. It returns `false` before `initialize()` and after `deinit()` completes. `getInstance()` throws when no instance exists. Initialization only means an SDK instance exists. It does not prove the merchant is authenticated, enrolled, or ready to accept a payment. While initialized, observe `sdk.readinessState` and require `isReadyForTransactions` before starting a tap. ## Deinitialize and initialize again Call both lifecycle operations on a worker thread. The helpers below switch to `Dispatchers.IO`: ```kotlin import android.app.Application import com.koardlabs.merchant.sdk.KoardMerchantSdk import com.koardlabs.merchant.sdk.domain.KoardEnvironment import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext suspend fun deinitializeSdk() = withContext(Dispatchers.IO) { if (KoardMerchantSdk.isInitialized()) { KoardMerchantSdk.getInstance().deinit() } check(!KoardMerchantSdk.isInitialized()) } suspend fun initializeSdk( application: Application, apiKey: String, environment: KoardEnvironment ) = withContext(Dispatchers.IO) { if (!KoardMerchantSdk.isInitialized()) { KoardMerchantSdk.initialize( application = application, apiKey = apiKey, environment = environment ) } } ``` Use `KoardEnvironment.UAT` with your UAT key or `KoardEnvironment.PROD` with your production key. Your app supplies the key again; the deinitialized SDK cannot recover it for you. Keep it in your app's existing credential/configuration mechanism and never print it in logs. Run deinitialization after payment and reader work has finished. Use an application/service coroutine scope that remains alive during cleanup, and await completion before offering Initialize or launching another SDK operation. The initialization flag is a snapshot, not a lock or a stream of cleanup progress; keep a separate UI busy state while deinitializing. After initialization, obtain a **new** `KoardMerchantSdk.getInstance()`. Discard references to the old SDK and subscribe to the new instance's readiness state. Then log in, choose the active location, and [enroll and check readiness](installing-sdk.md) again before accepting taps. Android deinit clears enrollment, so initialization alone cannot restore payment readiness. ## Verify the flow in the demo In the public 1.0.7 demo, choose **Settings → Reset SDK and sign out**. The demo deinitializes and reinitializes the SDK, reattaches NFC, and returns to Login. Sign in, select a location, and enroll again. Settings shows `isInitialized()` separately from reader readiness. The demo retains its own test configuration; this does not mean the SDK retains the key after deinit. The same flow works in the UAT and Production builds with the corresponding configuration. ## See also - [Transaction Metadata](transaction-metadata.md) - [Running Payments](running-payments.md) - [iOS SDK Lifecycle](../../ios-sdk/details/sdk-lifecycle.md) — iOS preserves device enrollment during deinit # Transaction Metadata on Android Attach your own JSON context to a sale, preauthorization, or refund with the optional `metadata` parameter. These examples require **Android SDK 1.0.7 or later** and work in UAT and Production. For a supplied AAR, follow [Manual AAR Installation](installing-sdk.md#fallback-manual-aar-installation). ## What is stored Metadata is an optional JSON object containing your application's context, such as an order ID, invoice reference, cart ID, or refund reason. Use it to reconcile Koard events with your own records and explain why a payment operation occurred. It belongs to the **individual transaction event** that the request creates. It is separate from `eventId` (idempotency), `transactionId` (the payment lifecycle), and payment settings such as amount, tax, tip, or surcharge. Putting those settings in metadata does not configure the payment. - Use string keys and JSON values: strings, numbers, booleans, nulls, arrays, or nested objects. Convert dates and custom application objects to JSON-compatible values first. - Omitting metadata stores no custom metadata for that event. An empty object `{}` is valid and is distinct from no value. - A refund can carry different metadata from the original sale. Later events do **not** automatically copy or merge the previous event's metadata. Pass the order reference again when you want it on a refund. - Metadata is stored by Koard for retrieval. It is not a processor configuration field or a guarantee of text appearing on a receipt. - Include business references only; do not include card numbers, CVVs, PINs, API keys, session tokens, or other secrets. ## Send metadata with a sale or preauth Amounts use `Int` minor currency units in SDK 1.0.7. Use Android's `org.json.JSONObject`. You do not need the SDK's internal serialization types. The example assumes the SDK is initialized, the merchant is signed in, a location is selected, and the reader is enrolled and ready. ```kotlin import android.app.Activity import com.koardlabs.merchant.sdk.KoardMerchantSdk import com.koardlabs.merchant.sdk.domain.KoardTransactionResponse import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.withContext import org.json.JSONObject suspend fun saleWithMetadata( activity: Activity, eventId: String, onResponse: suspend (KoardTransactionResponse) -> Unit ) { val metadata = JSONObject() .put("order_id", "order-123") .put("channel", "mobile") .put("fulfillment", JSONObject().put("pickup", true)) val flow = withContext(Dispatchers.IO) { KoardMerchantSdk.getInstance().sale( activity = activity, amount = 1250, // $12.50 in minor currency units currency = "USD", eventId = eventId, metadata = metadata ) }.flowOn(Dispatchers.IO) flow.collect { response -> onResponse(response) } } ``` Use a UUID4 for `eventId` and follow the [idempotency guidance](../../payments/details/idempotency.md) for retries. Handle every reader/transaction response as in [Running Payments](running-payments.md), and save the returned `transactionId`. Invoke this helper from a Main/UI coroutine. Flow work runs on IO; the callback stays on the caller's dispatcher for UI updates. Catch errors around the helper as in Running Payments. For an authorization without capture, replace `sale(...)` with `preauth(...)`; the `activity`, `amount`, `currency`, `eventId`, and `metadata` arguments above are the same. Run the sale or the preauth according to your payment flow. ## Send metadata with a refund ```kotlin import com.koardlabs.merchant.sdk.KoardMerchantSdk import com.koardlabs.merchant.sdk.domain.KoardTransaction import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.json.JSONObject suspend fun refundWithMetadata( transactionId: String, eventId: String ): KoardTransaction = withContext(Dispatchers.IO) { KoardMerchantSdk.getInstance().refund( transactionId = transactionId, amount = 500, // $5.00; null requests a full refund eventId = eventId, metadata = JSONObject() .put("order_id", "order-123") .put("return_id", "return-456") .put("reason", "customer_return") ).getOrThrow() } ``` This overload is a backend refund with no tap. `refundEmv(...)` and the Activity-based `refund(..., withTap = true)` overload also accept `metadata` for a card-present refund and emit a transaction flow. ## How it reaches Koard For sale/preauth taps, the SDK puts your JSON object inside the transaction data it passes to Visa KiC as `merchantCustomInfo`. KiC delivers that context to Koard's Android authorization endpoint, which stores your metadata with the event. The SDK handles the JSON/Base64 transport encoding; pass a `JSONObject`, not a pre-encoded string. For a backend refund, the SDK sends `metadata` in the refund request body. Omit it or pass `null` when no custom data is needed. Adding metadata does not change the payment amount, reader interaction, or response flow. ## Retrieve and view metadata The current SDK transaction response models do not expose a typed `metadata` property. Retrieve the raw transaction JSON through your backend or an API client using an API key authorized to read that merchant's transactions. Save the returned transaction ID, then call `GET /v1/transactions/{transaction_id}`. The top-level `metadata` belongs to the current event; `history[].metadata` belongs to each earlier event. For one specific operation, call `GET /v1/transactions/event/{event_id}` using the event ID sent with that operation. For example, from your backend or a local API client with `KOARD_API_KEY` set securely: ```bash KOARD_BASE_URL="https://api.uat.koard.com" TRANSACTION_ID="your-transaction-id" curl --fail-with-body --silent --show-error \ "$KOARD_BASE_URL/v1/transactions/$TRANSACTION_ID" \ -H "x-koard-apikey: $KOARD_API_KEY" \ -H "Accept: application/json" ``` For Production, use `https://api.koard.com` and a production key. A transaction ID and key must belong to the same environment and authorized account scope. A response after a refund may contain these fields (other transaction fields omitted): ```json { "transaction_id": "your-transaction-id", "event_id": "refund-event-id", "transaction_type": "refund", "metadata": { "order_id": "order-123", "return_id": "return-456", "reason": "customer_return" }, "history": [ { "event_id": "sale-event-id", "metadata": { "order_id": "order-123", "channel": "mobile" } } ] } ``` If a later event has `metadata: null`, check the original event in `history` or use its event lookup; null at the top level does not mean the sale's metadata was deleted. Likewise, older transactions and requests that omitted metadata can legitimately return null. To display metadata inside your app, have your backend retrieve it and return the fields your UI needs. Do not assume the SDK demo receipt or MMS transaction screen displays arbitrary metadata; the raw API response is the verification path described here. ## Verify in UAT and Production 1. Send a sale or preauth with a recognizable `order_id` and retain its event ID and returned transaction ID. 2. Retrieve that event in the same environment and confirm the metadata values and JSON types. 3. If testing a refund, pass a separate `return_id` and retain the sale's `order_id` explicitly. Retrieve the refund event and the transaction history to compare them. 4. Reinitializing or deinitializing the SDK does not erase metadata already stored in Koard. Log in again before continuing SDK operations. ## See also - [SDK Lifecycle and Deinitialization](sdk-lifecycle.md) - [Running Payments](running-payments.md) - [iOS Transaction Metadata](../../ios-sdk/details/transaction-metadata.md) # SDK Response Codes & Error Handling Understand how the Koard Android SDK surfaces errors and transaction outcomes — including the `KoardException` / `KoardError` types your app catches, transaction response codes, display messages, and error scenarios. **What you learn** * The two error channels: **`KoardException`** (thrown) vs **`KoardTransactionResponse`** (emitted) * The `KoardError` and `KoardErrorType` sealed hierarchy your app inspects for error details * How the SDK wraps all underlying Visa KiC errors into Koard types — you never handle raw KiC exceptions * The final transaction statuses: **Approve**, **Decline**, **Abort**, **Failure**, **AltService**, and **Unknown** * What `statusCode` means and the numeric codes the underlying Visa KiC kernel sends * Display message IDs shown during the tap-to-pay flow * Common abort and error scenarios and how to handle them ## Error Model Overview The SDK surfaces errors through **two channels** depending on context: | Channel | When | How | What to inspect | | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | --------------- | ---------------------------------------------------------------------- | | **`KoardException`** (thrown) | Non-transaction operations: enrollment, SDK init, API calls (`capture`, `refund`, `reverse`, `adjust`), validation | `try / catch` | `exception.error.errorType` — a `KoardErrorType` sealed class | | **`KoardTransactionResponse`** (emitted) | During sale, preauth, partial-completion, and Activity-based tap-refund flows | Callback / Flow | `response.actionStatus`, `response.finalStatus`, `response.statusCode` | **Refund overloads differ.** The reference-based `refund(transactionId, ...)` returns `Result`. The Activity-based `refund(..., withTap = true)` and `refundEmv(...)` return `Flow` for a new card tap. Handle thrown readiness errors as well as emitted events. **You never handle raw KiC exceptions.** The SDK catches every `KiCSdkException` from the Visa Kernel in the Cloud SDK and maps it to a `KoardException` with a typed `KoardErrorType`. Your app only needs to handle Koard types. ## KoardException & KoardError `KoardException` is the main exception thrown by the SDK for all non-transaction-flow errors. It wraps a `KoardError` with a human-readable message and a typed error classification: ```kotlin class KoardException( cause: Throwable? = null, val error: KoardError = KoardError( shortMessage = "Koard Merchant SDK has encountered a fatal error. ...", errorType = KoardErrorType.GeneralError ) ) : Exception(error.shortMessage, cause) data class KoardError( val shortMessage: String, // Human-readable error description val errorType: KoardErrorType // Typed error classification (sealed hierarchy) ) ``` **The underlying engine exception is never attached as `cause`.** When the SDK maps a `KiCSdkException` from the Visa Kernel in the Cloud, it deliberately does _not_ set it as the `KoardException.cause` — this keeps third-party SDK identifiers out of your crash reporters and means you never need a transitive dependency on the engine's exception types. The numeric code and any human-readable detail are folded into `error.shortMessage` and `error.errorType`. ### Catching KoardException ```kotlin try { withContext(Dispatchers.IO) { sdk.capture(transactionId, amount).getOrThrow() } } catch (e: KoardException) { when (e.error.errorType) { is KoardErrorType.KoardServiceErrorType.HttpError -> { val code = (e.error.errorType as KoardErrorType.KoardServiceErrorType.HttpError).errorCode showError("Server error (HTTP $code): ${e.error.shortMessage}") } is KoardErrorType.KoardServiceErrorType.ConnectionError -> showError("Network error — check your connection") is KoardErrorType.KoardServiceErrorType.Unauthorized -> showError("Session expired — please log in again") is KoardErrorType.VACEnrollmentError -> showError("Enrollment failed — re-enroll the device") else -> showError(e.error.shortMessage) } } ``` ## KoardErrorType Reference `KoardErrorType` is a sealed class hierarchy. Every error the SDK produces maps to one of these types. ### Top-Level Error Types | Error Type | When It Occurs | | --------------------------- | -------------------------------------------------------------------------- | | `GeneralError` | Catch-all for unmapped or unexpected errors | | `CertificateError` | TLS or certificate validation failure | | `BLEError` | Bluetooth/peripheral communication failure | | `MainThreadError` | SDK method called on the main thread (must use a worker thread) | | `NfcTransactionError` | NFC transaction-level failure | | `VACEligibilityError` | Device failed Visa Acceptance Cloud eligibility check (e.g., Android < 12) | | `DeviceNotProvisionedError` | Device has not been provisioned for Tap to Pay | | `VACEnrollmentError` | Enrollment with the Visa Acceptance Cloud failed | ### NotReady — Blocked Before the Tap Starts `sale()`, `preauth()`, `completePartialAuth()`, and `refundEmv()` refresh their readiness checks first and **throw** a `KoardException` whose `error.errorType` is a `KoardErrorType.NotReady.*` value if the SDK cannot transact. The Flow is never returned, so wrap the call itself — not just the collection — in `try / catch`. `KoardSdkReadiness.notReadyReason()` returns the same typed reason without attempting a transaction. | Error Type | Meaning | What to do | | ------------------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | | `KernelAppNotInstalled` | The Visa Tap to Pay Ready app isn't installed | Route the user to install it (`installKernelApp(activity)`) | | `DeveloperModeEnabled` | Developer options are on | Prompt the operator to disable developer mode | | `NotAuthenticated` | No merchant session | Call `login(...)` | | `NotEnrolled` | The device has not been enrolled | Set an active location, then call `enrollDevice()` | | `NoActiveLocation` | No active location selected | Call `setActiveLocation(locationId)` | | `ReaderNotStarted` | Enrolled with a location, but the reader connection was never started or was killed | Call `prepare()` to bring the session back up before retrying the tap | | `Preparing` | Enrolling, generating certificates, the payment processor is coming up, or a location switch is in flight | **Transient** — retry shortly | | `CertificateFailed(error)` | Certificate generation failed | Inspect `error`; re-run `refreshDeviceCertificates()` | | `EnrollmentFailed(error)` | Enrollment failed | Inspect `error`; see [Troubleshooting](/docs/setting-up-the-android-sdk/troubleshooting) | | `PaymentProcessorFailed(error)` | The thin client failed to start | Inspect `error`; `resetKernelService()` to release the IPC connection, then retry (optionally `prepare()` first to re-warm) | `KoardErrorType` and its nested `KoardServiceErrorType` / `KicConnectorError` are `sealed`, and 1.0.6 added the members above plus `KoardServiceErrorType.UnparseableResponse` and `KicConnectorError.KernelAppBusyWithAnotherMerchant`. Any exhaustive `when` over these types will stop compiling until you add the new branches or an `else`. ### KoardServiceErrorType — API / HTTP Errors Thrown when SDK methods call the Koard REST API (`capture`, `refund`, `reverse`, `adjust`, `getTransaction`, etc.): | Error Type | Description | | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | `HttpError(errorCode: Int)` | Server returned an HTTP error — inspect `errorCode` for the status (400, 401, 404, 500, etc.) | | `InvalidRequest` | Request validation failed before sending (e.g., negative amount, missing transaction ID) | | `NotFound` | Resource not found (404) | | `Unauthorized` | Missing or invalid [API key](/docs/appendix/glossary#api-key "Secret header credential for REST calls; v5 keys use scoped permissions.") / session (401) | | `UnexpectedError` | Unexpected server error or empty response body | | `UnparseableResponse` | The response body could not be deserialized | | `ConnectionError` | Network unreachable, DNS failure, or timeout | ### DeviceIntegrityError — Security Checks Thrown when the device fails security validation during enrollment or transaction preparation: | Error Type | KiC Code | Description | | ------------------------- | -------- | ------------------------------------------------------ | | `EmulatorDetected` | 1000 | Running on an emulator — use a physical device | | `RootDetected` | 1001 | Device is rooted or has superuser binaries | | `TamperDetected` | 1002 | Device tamper detection triggered | | `DeveloperModeEnabled` | 2000 | Developer options must be disabled | | `DebugModeEnabled` | 2001 | USB debugging must be disabled | | `HookDetected` | 2003 | Runtime instrumentation detected (Frida, Xposed, etc.) | | `GenericIntegrityFailure` | -1 | Generic device integrity attestation failure | ### TransactionErrorType — Card & Payment Errors These appear as the `errorType` on a `KoardException` when a transaction-level error is mapped from the KiC thin client. They correspond to EMV-level outcomes: | Error Type | Description | | ---------------------------------- | -------------------------------------------------- | | `TransactionAmountNonPositive` | Amount must be greater than zero | | `RefundMissingParentTransactionId` | Refund requires a parent transaction ID | | `CancelOrEnter` | Cardholder prompted to cancel or confirm | | `CardError` | Unrecoverable card data error | | `NotAuthorisedOrDeclined` | Issuer declined the transaction | | `PinRequired` | PIN entry is required | | `IncorrectPin` | Cardholder entered an incorrect PIN | | `ProcessingError` | Generic processing failure | | `TryAnotherCard` | Card cannot complete — try a different card | | `InsertOrSwipe` | Contactless not supported — use chip or mag-stripe | | `TryAnotherChoice` | Try a different payment method | | `Cancelled` | Transaction was cancelled | | `StrongCvm` | Strong Customer Verification required (SCA) | | `PinBypassed` | PIN entry was bypassed | | `PinNotProvided` | PIN was requested but not provided | | `TransactionNotAllowed` | Transaction type not allowed on this card/terminal | | `NotApplicable` | Status not applicable to this transaction type | | `UnknownStatus` | Unmapped status from the kernel | | `TransactionError` | Generic transaction error | | `EnableReader` | NFC reader needs to be enabled | | `NetworkError` | Network error during transaction processing | | `AuthenticationFailed` | Authentication with the payment backend failed | | `CouldNotAttestError` | Device attestation failed during transaction | | `AsiError` | Visa auth service interface error | | `TcConfigError` | Thin client configuration error | | `VACResponseFailedError` | VAC response indicated failure | | `VACResponseParseError` | Could not parse VAC response | | `TransactionInProgressError` | Another transaction is already in progress | | `DeviceDisabledError` | Device has been disabled for transactions | | `ErrorLoadingConfig` | Could not load transaction configuration | | `VACInternalError` | Internal VAC error | | `TransactionApprovedUploadFailed` | Transaction approved but receipt upload failed | ### KiC Connector Errors Thrown when the SDK cannot communicate with the Visa [Tap to Pay](/docs/appendix/glossary#tap-to-pay-android "Contactless acceptance on NFC Android devices using the Visa Tap to Pay Ready kernel.") Ready kernel app: | Error Type | KiC Code | Description | | ---------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `BindingError` | 92 | Failed to bind to the Visa kernel service | | `ConnectorSendError` | 93 | Sending a message to the kernel failed | | `KernelParseError` | 94 | Kernel response could not be parsed | | `ConnectorParseError` | 95 | Connector-side serialization failed | | `KernelAppNotInstalled` | 96 | Visa kernel app missing — install from Google Play | | `PlayProtectOrVerifyAppDisabled` | 97 | Google Play Protect must be enabled | | `KernelAppBusyWithAnotherMerchant` | 98 | Another merchant app on the device currently holds the lock on the Tap to Pay Ready kernel service (KiC multi-tenancy). Close the other app or wait for it to finish — calling `resetKernelService()` will **not** help because the lock is owned by a different process | ### KiC General Errors | Error Type | KiC Code | Description | | -------------------------------- | -------- | ---------------------------------------- | | `NoNetworkOrTimedOut` | 10 | Network unavailable or request timed out | | `UnsupportedAndroidVersion` | 21 | Device OS below Android 13 | | `TapToPayReadyAppUpdateRequired` | 62 | Visa Tap to Pay Ready app is outdated | ### KiC Eligibility Errors | Error Type | KiC Code | Description | | -------------------------------------------- | -------- | --------------------------------------- | | `UnsupportedOs` | 80 | OS build is unsupported | | `HardwareKeystoreNotPresent` | 81 | No hardware-backed keystore | | `ECEncryptionNotAvailable` | 82 | Elliptic-curve crypto unavailable | | `AESEncryptionNotAvailable` | 83 | AES crypto unavailable | | `DESEncryptionNotAvailable` | 84 | DES crypto unavailable | | `NfcNotAvailable` | 85 | NFC hardware missing or disabled | | `GooglePlayServicesNotAvailableOrOldVersion` | 86 | Google Play Services absent or outdated | | `EligibilityCheckFailed` | -1 | Generic eligibility failure | ### KiC Initialize Errors | Error Type | KiC Code | Description | | ----------------------- | -------- | ------------------------------------------- | | `AlreadyEnrolled` | 1 | Device already enrolled — no action needed | | `DeviceAuthPubKidEmpty` | 3 | Missing device-auth public key — re-enroll | | `VacDeviceIdEmpty` | 4 | VAC device ID not provided | | `XRandomValueEmpty` | 52 | Random nonce required by enrollment missing | | `Failed` | -1 | Generic initialization failure | ### KiC Prepare Errors Pre-transaction secure channel setup failures: | Error Type | KiC Code | Description | | -------------------------------- | -------- | ----------------------------------------------------- | | `AuthenticationFailed` | 7 | VAC authentication failed | | `SdkInitNotDone` | 11 | `init()` not completed before use | | `SdkEnrollNotDone` | 12 | `enrollDevice()` not completed — re-enroll | | `ErrorLoadingConfig` | 17 | Could not load config blobs | | `AsiError` | 18 | Visa auth service interface error | | `HardwareKeystoreNotPresent` | 20 | Hardware keystore missing during key prep | | `AttestationFailed` | 24 | Device attestation failed | | `DoLoginFailed` | 25 | Login exchange with Visa backend failed | | `NullLoginAssertion` | 26 | Login response missing assertion | | `NullLoginCrypto` | 27 | Login response missing crypto payload | | `NullLoginResponse` | 28 | Entire login response was null | | `NullLoginResponseBody` | 29 | Login HTTP body empty | | `NullLoginResponseAuthStatus` | 30 | Login response missing auth status | | `NullSharedSecret` | 31 | Shared secret not derived — re-enroll | | `NullSessionKeys` | 32 | Session keys missing — re-enroll | | `FailedResponseVerification` | 33 | MAC/signature mismatch — possible tampering | | `FailedMacTagVerification` | 34 | MAC tag verification failed | | `FailedAuthStatus` | 36 | Visa backend rejected authorization | | `EmptyAuthStatus` | 37 | Auth status element empty | | `GetSeedListFailure` | 49 | Could not fetch key-rotation seed list | | `CertificatePinningError` | 50 | TLS pinning failed — possible MITM | | `TransactionKeyDerivationFailed` | 51 | Could not derive transaction keys — re-enroll | | `KeyRotationNeeded` | 87 | Kernel requested key rotation (handled automatically) | | `KeyRotationNotNeeded` | 88 | Key rotation not needed (informational) | | `KeyRotationSuccess` | 89 | Key rotation completed (informational) | | `KeyRotationFailure` | 90 | Key rotation failed — re-enroll if transactions fail | | `KeyRotationNullResponse` | 91 | Kernel did not return rotation status | ## Transaction Response Flow `KoardTransactionResponse` emitted by `sdk.sale()` or `sdk.preauth()` can include an **action status** that tells your app what stage the transaction is in. When present, use this to drive your UI: | Action Status | Meaning | What to do | | ------------- | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | `OnProgress` | Transaction is in flight — the reader is active | Update your UI with the current `displayMessage` and `readerStatus` | | `OnFailure` | Read failure or terminal failure | A retryable hint with no final status keeps the same flow active; otherwise retain any transaction and display the failure | | `OnComplete` | The reader session has finished | Inspect `transaction.status` and nullable `finalStatus`, including partial approval and surcharge decisions | Use the complete [Running Payments handler](/docs/setting-up-the-android-sdk/running-payments#launching-a-sale-session). Do not force-unwrap `response.transaction`, assume `finalStatus` is always present, or show an approved receipt before checking partial and surcharge states. ## Final Transaction Statuses When present on completion, `finalStatus` has one of these values. It can be null; the returned transaction still carries its persisted status: | Final Status | Description | Typical Cause | | ------------------------ | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | | **`Approve`** | Transaction was authorized by the issuer | Successful payment — display receipt with approval code and transaction details | | **`Decline`** | Transaction was explicitly declined | Issuer denied the authorization, card restricted, insufficient funds, or Strong CVM required (SCA interface switch) | | **`Abort`** | Transaction was terminated before completion | User cancelled, PIN entry cancelled, device security issue, NFC read failure, timeout, or network loss | | **`Failure`** | An internal or system-level error prevented the transaction | SDK/kernel error, device misconfiguration, or unexpected processing failure | | **`AltService`** | Card requested an alternative service | The card network indicated that an alternative acceptance method should be used | | **`Unknown(rawStatus)`** | The kernel returned a status the SDK could not map | Inspect `rawStatus` for the original value; treat as a non-approval | The SDK consolidates the underlying processor response into these statuses so your app does not need to interpret raw processor-level codes. The original acquirer `responseCode` (ISO 8583 field 39) is still available in the transaction receipt for logging and support purposes. ## Persisted Transaction Status The reader `finalStatus` above describes the outcome of a single tap. The persisted transaction itself (`KoardTransaction.status`, also returned by `getTransaction`/`getTransactions` and post-reader operations) uses the `KoardTransactionStatus` enum: | Status | Description | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `PENDING` | Transaction created but not yet finalized | | `AUTHORIZED` | Funds authorized ([preauth](/docs/appendix/glossary#preauth "Authorization hold without capture; must later capture or reverse.")/hold) | | `CAPTURED` | Authorization captured/settled | | `SETTLED` | Settled with the processor | | `DECLINED` | Declined by the issuer | | `REFUNDED` | Fully or partially refunded | | `REVERSED` | Reversed/voided | | `CANCELED` | Canceled before completion | | `ERROR` | Errored out | | `SURCHARGE_PENDING` | Awaiting customer [surcharge](/docs/appendix/glossary#surcharge "Fee on eligible credit-card transactions to offset processing costs (not debit).") confirmation — call `sdk.confirm(transactionId, confirm = ...)` | | `UNKNOWN` | Unmapped status | A transaction is refundable when its status is `AUTHORIZED`, `CAPTURED`, `SETTLED`, or `PENDING` (exposed as `KoardTransactionStatus.isRefundable`). ## Acquirer Authorization Statuses Behind the scenes, the acquirer returns a more granular `authStatus` in the authorization response. The SDK maps these to the final statuses above, but they are available in the transaction details for advanced use cases: | Auth Status | Description | Maps to Final Status | | ------------------ | ------------------------------------------------------------------------ | ------------------------------------------- | | `Approve` | Issuer approved the transaction | `Approve` | | `Decline` | Issuer declined the transaction (also used for internal acquirer errors) | `Decline` | | `PartialApproval` | Issuer approved a lesser amount than requested | `Approve` (with reduced `authorizedAmount`) | | `InvalidPIN` | The PIN entered by the cardholder was incorrect | `Decline` | | `UnableToGoOnline` | The terminal could not connect to the acquirer for online authorization | `Decline` or `Abort` | | `AdditionalInfo` | Acquirer returned supplementary information (e.g., referral) | Varies | ## Transaction Response Details When a transaction completes (regardless of outcome), the `KoardTransactionResponse` contains the following fields: | Field | Type | Description | | ----------------------- | ------------------------------- | -------------------------------------------------------------------------------------------------------- | | `transactionId` | `String?` | Unique identifier for the transaction, when available | | `finalStatus` | `KoardTransactionFinalStatus?` | Terminal outcome, when available: `Approve`, `Decline`, `Abort`, `Failure`, or `AltService` | | `actionStatus` | `KoardTransactionActionStatus?` | Current action phase, when available: `OnProgress`, `OnFailure`, or `OnComplete` | | `readerStatus` | `KoardReaderStatus` | Reader state: `preparing`, `readyForTap`, `cardDetected`, `processing`, `complete`, etc. | | `displayMessage` | `String?` | Human-readable message from the reader/kernel | | `statusCode` | `Int?` | Numeric status code from the Visa KiC kernel — see [Status Code Reference](#status-code-reference) below | | `statusCodeDescription` | `String?` | Human-readable description of the status code (auto-generated from the code) | | `transaction` | `KoardTransaction?` | Full transaction object (populated on completion) | ## Status Code Reference The `statusCode` field on `KoardTransactionResponse` is a numeric integer forwarded from the underlying **Visa Kernel in the Cloud (KiC)** SDK. These codes are passed through on the transaction response for troubleshooting and logging. **These same codes drive the `KoardErrorType` mapping.** When the SDK catches a `KiCSdkException` with one of these codes, it maps it to the corresponding `KoardErrorType` documented in the [KoardErrorType Reference](#koarderrortype-reference) above. You don't need to handle numeric codes directly — use `KoardErrorType` pattern matching instead. For most apps, routing on `actionStatus` + `finalStatus` is sufficient. The `statusCode` is useful for **debugging**, **logging**, and handling edge cases like re-enrollment (`12`) or developer mode (`2000`). The status codes fall into several categories based on what layer of the KiC stack generated them: ### Connector Status (92–97) — Service Binding Failures These indicate problems communicating between the Koard SDK and the Visa Tap to Pay Ready app installed on the device. | Code | Description | What to do | | ---- | --------------------------------------------------- | ---------------------------------------------------------------- | | `92` | Failed to bind to the Visa kernel service | Ensure the Visa Tap to Pay Ready app is installed and up to date | | `93` | Sending a message to the kernel service failed | Retry the operation; if persistent, restart both apps | | `94` | Kernel response payload could not be parsed | Update the Visa Tap to Pay Ready app | | `95` | Connector-side serialization/deserialization failed | Update the Koard SDK to the latest version | | `96` | Visa kernel service app missing on device | Install the Visa Tap to Pay Ready app from Google Play | | `97` | Google Play Protect / Verify Apps is disabled | Enable Play Protect in Google Play settings | ### General Status (10, 21, 62) — Environment Readiness | Code | Description | What to do | | ---- | -------------------------------------------- | ------------------------------------------------ | | `10` | Network unavailable or SDK request timed out | Check network connectivity and retry | | `21` | Device OS level not supported by Tap to Pay | Device must run Android 13 (API 33) or later | | `62` | Visa Tap to Pay Ready app is outdated | Update the Tap to Pay Ready app from Google Play | ### Eligibility Status (80–86) — Device Capability Checks Returned when `checkKiCEligibility()` detects a device hardware or software limitation. | Code | Description | What to do | | ---- | ---------------------------------------------- | ------------------------------------------------------------ | | `80` | OS flavor/build is unsupported | Device uses an incompatible Android build (e.g., custom ROM) | | `81` | Device lacks a hardware-backed keystore | Device does not meet security requirements | | `82` | Elliptic-curve crypto APIs missing or disabled | Device crypto hardware insufficient | | `83` | AES crypto acceleration unavailable | Device crypto hardware insufficient | | `84` | DES crypto unavailable | Device crypto hardware insufficient | | `85` | NFC hardware missing or disabled | Enable NFC in device settings, or device has no NFC | | `86` | Google Play Services absent or out of date | Install or update Google Play Services | ### Initialize Status (1, 3, 4, 52) — Enrollment & Bootstrap | Code | Description | What to do | | ---- | ---------------------------------------------- | ---------------------------------------------------------- | | `1` | Device already enrolled for Tap to Pay | No action needed — the device is already set up | | `3` | Missing device-auth public key identifier | Re-run the enrollment flow | | `4` | Merchant/VAC device ID not provided | Ensure the SDK is configured with a valid merchant profile | | `52` | Random nonce required by enrollment is missing | Re-run the enrollment flow | ### Security Status (1000–2003) — Device Integrity | Code | Description | What to do | | ------ | ------------------------------------------- | ----------------------------------------------------------- | | `1000` | Emulator detected | Tap to Pay cannot run on emulators — use a physical device | | `1001` | Device rooted or superuser binaries present | Device must not be rooted | | `1002` | Device tamper detection triggered | Device has been modified and is not trusted | | `2000` | Developer options must be disabled | Disable developer mode before running transactions | | `2001` | USB debugging/logging must be disabled | Turn off USB debugging in developer options | | `2003` | Runtime hook/instrumentation detected | Remove any instrumentation frameworks (Frida, Xposed, etc.) | ### Prepare Status (7–91) — Pre-Transaction Secure Channel These codes occur during `startUpSdk()` or when the SDK prepares for a transaction. They relate to the secure channel between the device and the Visa backend. | Code | Description | What to do | | ---- | -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | | `7` | VAC authentication call to Visa failed | Check API credentials and network connectivity | | `11` | `init()` not completed before use | Complete SDK initialization before starting transactions | | `12` | `enrollDevice()` not completed | Device needs enrollment — show the enrollment UI and re-enroll. This also occurs if the Tap to Pay Ready app was reinstalled | | `17` | Could not load enrollment/transaction config blobs | Re-initialize the SDK | | `18` | ASI (Visa auth service interface) returned error | Transient backend issue — retry | | `20` | Hardware keystore missing when preparing keys | Device does not meet security requirements | | `24` | Device attestation failed or invalid | Re-enroll the device; ensure Play Protect is enabled | | `25` | Login exchange with Visa backend failed | Check network; retry | | `26` | Login response missing assertion blob | Transient backend issue — retry | | `27` | Login response missing crypto payload | Transient backend issue — retry | | `28` | Entire login response was null | Transient backend issue — retry | | `29` | Login HTTP body empty | Transient backend issue — retry | | `30` | Login response missing auth status | Transient backend issue — retry | | `31` | Shared secret not derived | Re-enroll the device | | `32` | Session keys missing | Re-enroll the device | | `33` | MAC/signature mismatch in response | Possible tampering — re-enroll the device | | `34` | MAC tag verification failed | Possible tampering — re-enroll the device | | `36` | Visa backend explicitly rejected authorization | Check merchant configuration with Koard support | | `37` | Auth status element empty | Transient backend issue — retry | | `49` | Could not fetch key-rotation seed list | Check network connectivity | | `50` | TLS pinning check failed | Possible man-in-the-middle — check network security | | `51` | Could not derive transaction keys | Re-enroll the device | ### Key Rotation Status (87–91) | Code | Description | What to do | | ---- | ------------------------------------------- | ------------------------------------------------- | | `87` | Kernel requested key rotation | SDK handles this automatically — no action needed | | `88` | Key rotation already satisfied (not needed) | Informational — no action needed | | `89` | Key rotation completed successfully | Informational — no action needed | | `90` | Key rotation failed | Re-enroll the device if transactions fail | | `91` | Kernel did not return key rotation status | Re-enroll the device if transactions fail | ### Prepare Progress (70–75) — `prepare()` Warm-Up The `sdk.prepare()` warm-up flow emits `KoardPrepareResponse` objects whose `status` is a `KoardPrepareStatus`. Codes 70–75 are the normal lifecycle; anything in the 80–199 range is surfaced as `KoardPrepareStatus.Error(code)`, and an unrecognized code becomes `KoardPrepareStatus.Unknown(code)`. These progress codes are informational — wait for `Done` (or an `Error`) rather than handling each one. | Code | `KoardPrepareStatus` | Meaning | | ---------- | -------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | `70` | `Called` | KiC accepted the `prepare()` invocation | | `71` | `AuthenticationInProgress` | Authenticating the device against the backend | | `72` | `AttestationInProgress` | Performing Play Integrity / device attestation | | `73` | `GettingConfigurations` | Downloading transaction configurations | | `74` | `ParsingConfigurations` | Parsing the downloaded configurations | | `75` | `Done` | Prepare completed — SDK is ready for transactions | | `80`–`199` | `Error(code)` | Prepare did not reach `Done` (auth/attestation failure, key rotation, network error, etc.) — retry on the next user action | ### Transaction Status — In-Progress Codes These codes appear during an active transaction and are reflected in the `readerStatus` field: | Code | Reader Status | Description | | ----- | --------------- | ----------------------------------------------------------- | | `109` | `readyForTap` | POS state started — reader is waiting for a card tap | | `112` | `preparing` | POS state message — reader is preparing for the transaction | | `106` | `readCompleted` | Card read completed successfully | ### Generic Failure (-1) A status code of `-1` indicates a generic failure. The SDK uses the `statusCodeDescription` field to provide more context: | Description Contains | Meaning | | -------------------------------- | -------------------------------------------- | | `"eligibility"` | Generic eligibility evaluation failure | | `"initialise"` or `"enrol"` | Generic initialization or enrollment failure | | `"integrity"` or `"attestation"` | Device integrity attestation failed | **Status code 12 (enrollment not done)** deserves special handling. The demo app treats `statusCode == 12` during `OnFailure` as retryable (alongside `42`, `53`, and a null status code) — it prompts "Please tap again" rather than surfacing an error. If the code persists, the Tap to Pay Ready app was most likely reinstalled or had its data cleared; the merchant app must then clear its stored enrollment data (`clearEnrollmentState()`) and re-run `enrollDevice()`. Re-enrollment is never automatic. ## Display Message IDs During the tap-to-pay flow, the SDK emits display messages via `displayMessage` on each `OnProgress` event. These correspond to standard EMV message identifiers from the kernel: | Message | ID | Description | | --------------------------------------- | ---- | -------------------------------------------------------------------- | | Approved | `03` | Authorization obtained — transaction approved | | Cancel or Enter | `05` | Prompt to cancel or confirm | | Card Error | `06` | Unrecoverable card data error | | Not Authorized / Declined | `07` | Transaction was declined by the issuer | | Please remove card | `10` | Card not yet removed from the reader field | | Please try again | `13` | Recoverable error — retry the tap | | Welcome | `14` | Idle state — reader is ready | | Present card | `15` | Prompt the cardholder to tap | | Processing | `16` | Transaction is being processed | | Card read OK / Remove card | `17` | Card was read successfully — may be removed | | Please insert or swipe card | `18` | Contactless not supported — try contact/mag-stripe | | Please present one card only | `19` | Card collision detected — present only one card | | Approved. Please Sign | `1A` | Approved; signature required | | Authorizing. Please Wait | `1B` | Online authorization in progress | | Insert, swipe, or try another card | `1C` | Contactless failed — use another interface or card | | Please insert card | `1D` | Chip card should be inserted into the slot | | _(Empty string)_ | `1E` | Clear the display | | See Phone for instructions | `20` | Mobile device CVM required (Touch ID, Face ID, etc.) | | Present card again | `21` | Recoverable error — present the card again | | Practice Mode | `40` | Successful test/practice transaction | | Partial Approval | `43` | Issuer approved a lesser amount | | Cancelled for Device Security | `46` | Transaction cancelled due to a device security issue | | Cancelled | `47` | Generic transaction cancellation | | Try another card - No contact interface | `48` | Card returned GPO error (SW 6984) — transaction aborted | | Strong CVM | `49` | SCA issuer response requires interface switch — transaction declined | ## Abort and Error Scenarios The SDK returns an `Abort` or `Failure` final status in several well-defined situations. Understanding these helps you build robust error handling: ### Transaction Abort Scenarios | Scenario | What Happens | Message ID | | ------------------------------- | ------------------------------------------------------------------------------- | ---------- | | **User cancels PIN entry** | User selects "Cancel Transaction" on the PIN keypad | — | | **PIN session timeout** | 1 minute of inactivity on the PIN keypad | — | | **PIN keypad interrupted** | Another app covers the PIN screen | — | | **Network loss during PIN** | Network drops before the PIN event is sent | — | | **Split screen mode** | Device enters split screen while on PIN screen — sends `CVEntrySecurity` cancel | — | | **Device security issue** | Security configuration problem detected | `46` | | **Generic cancellation** | User or system cancelled the transaction | `47` | | **Card NFC failure (GPO 6984)** | Card cannot complete contactless — abort with `MACompletion` indicator | `48` | | **Strong CVM / SCA switch** | Issuer requires contact interface (not supported) — decline with `MACompletion` | `49` | | **Developer mode enabled** | Developer options are on — reader blocks the transaction | — | ### OnFailure Status Codes When `actionStatus` is `OnFailure`, check `statusCode` for the specific reason: | Status Code Constant | Description | | ---------------------------------- | ------------------------------------------------------------------ | | `TRANSACTION_WINDOW_FOCUS_CHANGED` | The transaction window lost focus (another app came to foreground) | | `CAMERA_IS_ACTIVE` | Device camera is active — conflicts with the secure NFC session | | `DEVELOPER_MODE_ENABLED` | Developer options are enabled on the device | | `NFC_NOT_AVAILABLE` | Device NFC is disabled or unavailable | | `DEVICE_NOT_ENROLLED` | Device has not completed enrollment | | `SESSION_TIMEOUT` | The transaction session timed out | **Developer Mode**: The most common cause of unexpected transaction failures during development. Always disable developer mode before running transactions. Follow the workflow: **Enable dev mode → Install app → Disable dev mode → Run transactions**. ### Completion Indicators The receipt field `emv.tx.tm.CompletionIndicator` tells you how the transaction concluded at the kernel level: | Value | Meaning | | ---------------- | ----------------------------------------------------------------------------------------------------------------- | | `FullCompletion` | Transaction completed normally through the full authorization flow | | `MACompletion` | Transaction was terminated by the kernel (Merchant Application completion) — typically an abort or forced decline | ## Handling Responses in Practice The SDK uses two error channels. Here is a complete pattern for handling both: ### Channel 1: Transaction Flow (KoardTransactionResponse) For `sale()`, `preauth()`, `completePartialAuth()`, and tap-based refunds, use the complete [reader response handler in Running Payments](/docs/setting-up-the-android-sdk/running-payments#launching-a-sale-session). Keep collecting the same flow for retryable read hints with no final status; do not start another payment. Classify completion by the returned transaction status as well as the nullable `finalStatus`, handling partial approval and surcharge decisions before a completed receipt. A flow that ends without a terminal event needs outcome recovery. ### Channel 2: API & SDK Operations (KoardException) For `capture()`, `reverse()`, `refund()`, `adjust()`, `enrollDevice()`, `setActiveLocation()`, and other non-tap operations — errors are thrown as `KoardException`: ```kotlin private suspend fun capturePayment(transactionId: String, amount: Int) { try { val transaction = withContext(Dispatchers.IO) { sdk.capture(transactionId, amount).getOrThrow() } when (transaction.status) { KoardTransactionStatus.CAPTURED, KoardTransactionStatus.SETTLED -> showReceipt(transaction) else -> reconcileTransaction(transaction) } } catch (e: CancellationException) { throw e } catch (e: KoardException) { when (val errorType = e.error.errorType) { // HTTP errors from the Koard API is KoardErrorType.KoardServiceErrorType.HttpError -> showError("Server error (HTTP ${errorType.errorCode}): ${e.error.shortMessage}") is KoardErrorType.KoardServiceErrorType.ConnectionError -> showError("Network error; check your connection and reconcile the capture outcome") is KoardErrorType.KoardServiceErrorType.Unauthorized -> showError("Session expired — please log in again") is KoardErrorType.KoardServiceErrorType.NotFound -> showError("Transaction not found") is KoardErrorType.KoardServiceErrorType.InvalidRequest -> showError("Invalid request: ${e.error.shortMessage}") // Device integrity failures is KoardErrorType.DeviceIntegrityError.DeveloperModeEnabled -> showError("Disable developer mode before processing payments") is KoardErrorType.DeviceIntegrityError -> showError("Device security check failed: ${e.error.shortMessage}") // Enrollment issues is KoardErrorType.VACEnrollmentError -> promptReEnrollment(e.error.shortMessage) is KoardErrorType.VACEligibilityError -> showError("Device not eligible for Tap to Pay: ${e.error.shortMessage}") // KiC connector/kernel errors is KoardErrorType.KicConnectorError.KernelAppNotInstalled -> showError("Install the Visa Tap to Pay Ready app from Google Play") is KoardErrorType.KicConnectorError -> showError("Kernel communication error: ${e.error.shortMessage}") is KoardErrorType.KicPrepareError.SdkEnrollNotDone -> promptReEnrollment("Device needs re-enrollment") // Fallback else -> showError(e.error.shortMessage) } } } ``` ## Next Steps * Review the [Running Payments](/docs/setting-up-the-android-sdk/running-payments) guide for the complete payment flow implementation * See the [Demo App](/docs/setting-up-the-android-sdk/demo) for a working example of response handling in `MainScreenViewModel` * Consult the [API Response Codes](/api-reference/response-codes) for HTTP-level status codes from the Koard REST API # Supported Devices & NFC Tap Location A guide to compatible Android devices for [Tap to Pay](/docs/appendix/glossary#tap-to-pay-android "Contactless acceptance on NFC Android devices using the Visa Tap to Pay Ready kernel."), device requirements, and where customers should tap their card on each device. **What you learn** In this guide, you'll learn: * Which Android devices are compatible with Tap to Pay * Minimum device requirements for NFC contactless payments * Where the NFC antenna is located on popular devices * How to instruct customers to tap their card correctly ## Device Requirements To accept Tap to Pay on Android, the merchant's device must meet **all** of the following requirements: | Requirement | Details | | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Android version** | Android 13 (API level 33) or later | | **NFC hardware** | Built-in NFC chip (virtually all modern flagship and mid-range Android phones) | | **Hardware keystore** | TEE or StrongBox-backed keystore for secure key storage | | **Google Play Services** | Google Mobile Services (GMS) must be installed and up to date | | **Google Play Protect** | Must be enabled for device integrity verification | | **Developer mode** | Must be **disabled** during live transactions | | **Device integrity** | Passes Play Integrity: release-signed APK, locked bootloader, no root/Magisk. No custom ROMs | | **Visa Kernel app** | Visa Tap to Pay Ready **25.06.10 or later** must be installed, either directly from Google Play or through the optional in-app flow — see [installation options](/docs/setting-up-the-android-sdk/installing-the-sdk#step-5-install-visa-tap-to-pay-ready) | **Developer Mode**: Tap to Pay transactions will fail if developer mode is enabled. Always disable developer mode before processing payments. See the [Installing the SDK](/docs/setting-up-the-android-sdk/installing-the-sdk) guide for the recommended workflow. ### Play Integrity Gating In addition to the hardware/software requirements above, the underlying contactless engine enforces **Play Integrity** at enrollment time. The device must be running a **release-signed APK on a locked bootloader**, with **no root/Magisk** and **Developer Options turned off**. Debug-signed builds installed via Run ▶ from Android Studio are rejected during enrollment, even on otherwise-eligible hardware. ### Eligibility Check The SDK provides a built-in eligibility check that verifies device requirements before attempting a transaction. Call `checkKiCEligibility()` on a worker thread; it returns a `KoardKiCEligibility` with an `isEligible` flag and the set of `failureCodes` when the device is not eligible: ```kotlin withContext(Dispatchers.IO) { val sdk = KoardMerchantSdk.getInstance() val eligibility = sdk.checkKiCEligibility() if (eligibility.isEligible) { // Device is eligible for Tap to Pay } else { // Handle ineligibility — inspect failure codes eligibility.failureCodes.forEach { code -> Log.w("TapToPay", "Eligibility failure code: $code") } } } ``` The numeric `failureCodes` map to the eligibility error codes documented in [SDK Response Codes](/docs/setting-up-the-android-sdk/sdk-response-codes#kic-eligibility-errors). ### Visa Kernel App Tap to Pay requires Visa Tap to Pay Ready on the device. Merchants can install it directly from Google Play or use an optional in-app Install action. The [Installing the SDK guide](/docs/setting-up-the-android-sdk/installing-the-sdk#step-5-install-visa-tap-to-pay-ready) covers both options and how to confirm installation before enrollment. ## Tested & Certified Devices The following devices have been tested and certified by Visa for use with the Kernel in the Cloud (KiC) Tap to Pay Ready application: | Device | Android Version | Form Factor | | ------------------ | --------------- | ----------- | | Google Pixel 4 | Android 13 | Phone | | Google Pixel 6 | Android 15 | Phone | | Google Pixel 8 | Android 16 | Phone | | Google Pixel 9 | Android 15 | Phone | | Samsung Galaxy S22 | Android 13 | Phone | | Samsung Galaxy S23 | Android 14 | Phone | | Oona Tablet | — | Tablet | **Not limited to this list**: These are the devices Visa has explicitly tested and certified. In practice, **any Android device** that meets all the requirements listed above (Android 13+, NFC, hardware keystore, GMS) should work with Tap to Pay. The SDK's `checkKiCEligibility()` check will confirm compatibility at runtime. ### Full Supported Device List Beyond the Visa-certified list, Tap to Pay on Android is supported across a wide range of manufacturers and models. The following devices are confirmed compatible (when running Android 13+): | Brand | Supported Models | | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **Asus** | Zenfone 9, Zenfone 10, Zenfone 11 Ultra, Zenfone 12 Ultra, ROG Phone 6, ROG Phone 7, ROG Phone 8, ROG Phone 9 | | **Google Pixel** | Pixel 6, Pixel 6a, Pixel 7, Pixel 7a, Pixel 8, Pixel 8a, Pixel 9, Pixel 9a, Pixel 10 | | **Nokia** | G22, G42, G60, G400, X30, XR21 | | **Honor** | 70, 70 Lite, 80, 90, 90 Lite, Magic5, Magic6, Magic7, Magic8 | | **Infinix** | Hot 30, Hot 40, Hot 50, Hot 60i, Zero 20, Zero 30, Zero 40 | | **Motorola** | Edge 2023, Edge 2024, Edge 2025, Moto G 2025, Razr 40, Razr 50, Razr 60 | | **OnePlus** | Nord 3, Nord 4, Nord 5, Nord CE3, Nord CE4, Nord CE5, Nord N30, 11, 11R, 12, 12R, 13, 13R | | **Oppo** | A60, A77, A78, A98, Find X5, Find X6, Find X7, Find X8, Find X9, Reno8, Reno9, Reno10, Reno11, Reno12, Reno13, Reno14, Reno15 | | **Samsung Galaxy** | A04s, A05s, A13, A14, A15, A16, A17, A24, A25, A26, A33, A34, A35, A36, A53, A54, A55, A56, A73, S22, S23, S24, S25, S26 Ultra, Z Flip4, Z Flip5, Z Flip6, Z Flip7, Z Fold4, Z Fold5, Z Fold6, Z Fold7 | | **Xiaomi** | 12, 12S, 12T, 13, 13T, 14, 14T, 15, 15T, Redmi 12, Redmi 12C, Redmi 13, Redmi 13C, Redmi 14C, Redmi 15, Redmi Note 12, Redmi Note 13, Redmi Note 14 | **Always verify at runtime**: NFC support can vary by region and carrier variant — especially for mid-range devices. Use the SDK's `checkKiCEligibility()` check at runtime rather than relying solely on a static device list. ## NFC Antenna Location & Tap Guidance The NFC antenna location determines where the customer should hold or tap their contactless card or device. Getting this right is critical for a smooth payment experience. ### General Rule On virtually all Android phones, the **NFC antenna is located on the back of the device, in the upper-center area** (roughly behind the rear camera module). Customers should hold their card flat against the **upper-middle portion of the phone's back**. ### NFC Antenna Location Summary The table below summarizes NFC antenna placement by manufacturer. For per-model details, see the device tables in the [Full Supported Device List](#full-supported-device-list) above. | Manufacturer | NFC Antenna Location | Tap Zone Guidance | | ------------------------------ | ------------------------------------------------------- | -------------------------------------------------- | | **Asus** (Zenfone / ROG Phone) | Center to upper-center back | Hold card against the center-top third of the back | | **Google Pixel** | Upper-center back, near/behind the rear camera bar | Hold card against the top third of the back | | **Nokia** | Upper-center back | Hold card against the top third of the back | | **Honor** | Upper-center back; Magic series slightly above midpoint | Hold card against the top third of the back | | **Infinix** | Upper-center back | Hold card against the top third of the back | | **Motorola** (Edge / Moto G) | Upper-center back, near the camera or Motorola logo | Hold card against the top third of the back | | **Motorola** (Razr foldables) | Upper half of back (when folded) | Tap on the upper portion of the folded device | | **OnePlus** (flagships) | Upper-center back, near camera module | Hold card against the top third of the back | | **OnePlus** (Nord series) | Upper-center back | Hold card against the top third of the back | | **Oppo** (Find X series) | Center back, near camera module | Hold card against the center of the back | | **Oppo** (A / Reno series) | Upper-center back | Hold card against the top third of the back | | **Samsung Galaxy S** series | Center back, slightly above the midpoint | Hold card against the center of the back | | **Samsung Galaxy A** series | Upper-center to center back (varies by tier) | Hold card against the center of the back | | **Samsung Galaxy Z Flip** | Upper half of the back (when folded) | Tap on the upper portion of the folded device | | **Samsung Galaxy Z Fold** | Center of the back panel (when closed) | Tap on the center of the back when folded | | **Xiaomi** (flagships) | Upper-center back, near camera module | Hold card against the top third of the back | | **Xiaomi** (Redmi series) | Upper-center back | Hold card against the top third of the back | | **Tablets** | Varies — typically center back or near one edge | Check manufacturer documentation | ### Visual Tap Guide For the best tap experience, instruct customers to: 1. **Remove the card from any wallet or sleeve** — Other cards or RFID-blocking material can interfere with the NFC signal 2. **Hold the card flat** against the back of the phone — Do not tap at an angle 3. **Position the card** over the NFC antenna zone (upper-center back on most devices) 4. **Hold steady for 1–2 seconds** — Do not pull away until the phone confirms the read 5. **Listen/watch for confirmation** — The device will display a status message and/or vibrate when the card is read successfully ```plaintext ┌─────────────────────┐ │ │ ← Phone (back view) │ ┌───────────┐ │ │ │ 📷 Camera │ │ │ └───────────┘ │ │ ╔═══════════════╗ │ │ ║ NFC ANTENNA ║ │ ← Tap card here │ ║ TAP ZONE ║ │ │ ╚═══════════════╝ │ │ │ │ │ │ │ │ │ └─────────────────────┘ ``` **Phone cases**: Thin phone cases generally do not interfere with NFC reads. However, thick rugged cases, metal cases, or cases with built-in card holders may block or weaken the NFC signal. If customers experience read failures, try removing the case. ### Handling Tap Failures If the card does not read on the first attempt: 1. Reposition the card slightly — move it toward the camera area 2. Ensure the card is flat and not angled 3. Remove any phone case that may be interfering 4. Check that the device screen shows the "Present card" or "Tap card" prompt 5. If the issue persists, the SDK will surface an appropriate [display message](/docs/setting-up-the-android-sdk/sdk-response-codes#display-message-ids) or an `OnFailure` status code such as `NFC_NOT_AVAILABLE` ## Troubleshooting Device Compatibility | Issue | Possible Cause | Solution | | ------------------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Eligibility check returns failure codes | Device doesn't meet one or more requirements | Inspect the failure codes from `checkKiCEligibility()` and address each (e.g., enable Google Play Protect, update Google Play Services, disable developer mode) | | NFC transactions fail on a supported device | Developer mode enabled | Disable developer mode in Settings → Developer options | | Card not reading | NFC disabled in device settings | Go to Settings → Connected devices → Connection preferences → NFC and ensure it's toggled on | | Intermittent read failures | Card positioned incorrectly | Guide the customer to tap in the correct NFC zone (see table above) | | "Kernel app not found" error | Visa Tap to Pay Ready app not installed | Install directly from Google Play or through the optional in-app flow; see [installation options](/docs/setting-up-the-android-sdk/installing-the-sdk#step-5-install-visa-tap-to-pay-ready) | | Transactions fail after app update | Developer mode was re-enabled for deployment | Disable developer mode after installing updates, then restart the device | ## Next Steps * [Installing the SDK](/docs/setting-up-the-android-sdk/installing-the-sdk) — Set up your development environment * [Running Payments](/docs/setting-up-the-android-sdk/running-payments) — Process your first Tap to Pay transaction * [SDK Response Codes](/docs/setting-up-the-android-sdk/sdk-response-codes) — Understand transaction outcomes and error handling * [Running the Demo App](/docs/setting-up-the-android-sdk/demo) — Test with the sample application # Troubleshooting Real-world fixes for issues merchants commonly hit when enrolling devices or running their first tap. If a device fails the SDK eligibility check, see [Supported Devices & NFC Tap Location](/docs/setting-up-the-android-sdk/supported-devices). For SDK error codes returned during a transaction, see [SDK Response Codes](/docs/setting-up-the-android-sdk/sdk-response-codes). **What you learn** In this guide, you'll learn: * How to fix the "tap was immediately cancelled" issue * How to fix "Kernel App is not installed" when the kernel app is installed * How to resolve enrollment failures caused by device clock drift * How to recover from a `TamperDetected` error on every sale after a failed enrollment * What to do when the kernel is busy with another merchant app * How to fix the "SDK not initialized" error * Which device settings must be enabled before a merchant's first transaction * A pre-flight checklist to run before contacting Koard support ## Tap is Immediately Cancelled ### Symptom The merchant enrolls successfully and the SDK reports the device as eligible. When they attempt their first sale, the reader shows "Present card", but the moment the customer taps, the transaction is **immediately cancelled** — the SDK emits an `Abort` (or `OnFailure`) before the card is read. This has been observed across several Android devices, with **Samsung Galaxy S21** being the most common — but any Android device that exposes a separate **"NFC and contactless payments"** (or similarly named) toggle can hit it. ### Cause Many Android devices ship with **two distinct NFC settings**: 1. **NFC** — the basic NFC radio toggle (enabled by default on most devices). 2. **NFC and contactless payments** — a separate setting that authorizes the device to use NFC for **payment-related** Host Card Emulation (HCE) and [Tap to Pay](/docs/appendix/glossary#tap-to-pay-android "Contactless acceptance on NFC Android devices using the Visa Tap to Pay Ready kernel.") flows. Different OEMs label this differently (e.g. "NFC and contactless payments", "NFC and contactless transactions", "Contactless payments"). The SDK eligibility check verifies that NFC hardware is present and the radio is on, which is why enrollment and the eligibility check both pass. However, if the secondary **"NFC and contactless payments"** setting is disabled, the Visa Tap to Pay Ready kernel cannot complete the contactless card read — the device's payment subsystem blocks the read and the transaction is cancelled before any card data is exchanged. ### Fix On the merchant's device: 1. Open **Settings** 2. Tap **Connections** (or **Connected devices** depending on the OEM and Android version) 3. Tap **NFC and contactless payments** (sometimes labeled **NFC and contactless transactions** or simply **Contactless payments**) 4. Ensure the master toggle is **On** 5. Optionally set **Contactless payments → Default payment app** to your merchant app, or leave it as the system default if your app does not require the system payment role After enabling the setting, retry the sale. No re-enrollment is required. **Why the eligibility check doesn't catch this**: `checkKiCEligibility()` verifies that NFC hardware exists and the system NFC radio is enabled. The OEM-specific "contactless payments" toggle gates a higher layer (the payment HCE subsystem) and is not visible to the eligibility API. If a merchant sees taps cancelled instantly, always confirm this setting before deeper debugging. If "NFC and contactless payments" is enabled and the tap is still cancelled instantly, check whether a wallet app (Samsung Wallet, Google Wallet, etc.) is set as the default and is intercepting the tap. Temporarily clear the default payment app under **NFC and contactless payments → Contactless payments**, then retry. ## "Kernel App is not installed" When It Is Installed ### Symptom Enrollment fails with `IllegalStateException: Kernel App is not installed`, and the SDK readiness log shows `kernelApp=false`, even though the Visa Tap to Pay Ready kernel app (`com.visa.kic.app.kernel`) is installed and up to date on the device. Reinstalling the kernel app does not help. ### Cause Android 11 and later only let an app see other installed apps that its manifest declares. This is called package visibility. The Koard SDK finds the kernel by checking that `com.visa.kic.app.kernel` is installed, and then binds to the kernel's service to run every tap. Without a declaration, Android hides the kernel from your app, so the SDK reports it as missing. The Koard SDK does not contribute this declaration. Your app's manifest must include it. ### Fix Add a `` element to your app's `AndroidManifest.xml`, directly inside ``: ```xml ``` Rebuild and reinstall the app, then run enrollment again. No change to the kernel app is needed. See [Step 2: Configure AndroidManifest.xml Permissions](/docs/setting-up-the-android-sdk/installing-the-sdk) for the complete manifest. **What the declaration grants**: visibility of exactly one package, the Visa kernel. It does not let your app see or query any other installed app, and it needs no permission prompt. It is narrower than the `QUERY_ALL_PACKAGES` permission, which Google Play restricts and which Tap to Pay does not need. ## Enrollment Fails or Returns Errors ### Symptom Calls to `enrollDevice()` (or the SDK's enrollment flow) fail with errors such as: * `InvalidRequest` — "Developer mode is enabled", "Device already enrolled", or no active location was set. `enrollDevice()` requires an authenticated session **and** a prior `setActiveLocation(locationId)` call * `VACEnrollmentError` * `AuthenticationFailed` (status code `7`) * `AttestationFailed` (status code `24`) * `CouldNotAttestError` * Generic prepare errors in the `7`–`51` range The merchant sees an "Enrollment failed" screen and cannot proceed to take their first tap. ### Cause Enrollment establishes a secure channel between the device and the Visa Acceptance Cloud (VAC). That handshake includes a **device attestation step** that is sensitive to three things: 1. **Developer mode is enabled** — most enrollment endpoints reject devices with developer options on, because the device cannot produce a trustworthy attestation. 2. **The device clock has drifted** — VAC verifies signed timestamps during the handshake. If the device clock is off by more than a few minutes (a "clock drift" issue), signatures fail to verify and attestation is rejected. This is most common on devices that have been offline for a long time, recently factory-reset, or have automatic time disabled. 3. **Google Play Protect is disabled** — Play Protect provides the integrity signals VAC uses to attest the device. With Play Protect off, the attestation payload is incomplete and enrollment is rejected. ### Fix Walk the merchant through all three checks before retrying enrollment: #### 1. Disable Developer Mode 1. Open **Settings → System → Developer options** 2. Toggle **Developer options** to **Off** 3. If the option is missing entirely, developer mode is already off — proceed to the next step If developer mode was on, restart the device after disabling it. #### 2. Enable Automatic Date & Time 1. Open **Settings → General management → Date and time** (Samsung) or **Settings → System → Date & time** (stock Android) 2. Toggle **Automatic date and time** to **On** 3. Toggle **Automatic time zone** to **On** 4. Wait a few seconds for the device to sync with the network time source This corrects clock drift and is the most common fix when enrollment fails on a device that previously worked. #### 3. Enable Google Play Protect 1. Open the **Google Play Store** app 2. Tap your profile icon (top right) → **Play Protect** 3. Tap the **gear icon** (settings) in the top right of the Play Protect screen 4. Toggle **Scan apps with Play Protect** to **On** Play Protect must be enabled for the device attestation step to succeed. If the merchant cannot enable Play Protect (for example on a device without Google Mobile Services), the device is not eligible for Tap to Pay. After all three checks pass, restart the device and retry enrollment. If the SDK still returns an enrollment error, capture the `statusCode` and `errorType` and consult the [SDK Response Codes](/docs/setting-up-the-android-sdk/sdk-response-codes#kic-prepare-errors) reference. **Why clock drift matters**: The VAC attestation handshake relies on signed timestamps to prevent replay attacks. The SDK's enrollment flow includes a fresh nonce and a device-side timestamp; if the device clock is more than a few minutes off the server clock, the server rejects the timestamp and the handshake fails — often surfacing as a generic `AuthenticationFailed` (code `7`) or `AttestationFailed` (code `24`). Enabling automatic time syncs the device against a network time source and eliminates the drift. ## `TamperDetected` on Every Sale After a Failed Enrollment ### Symptom `enrollDevice()` (or `enableNfcTransactionsAsync(...)`) appears to finish but reports a non-success status. Afterwards, **every** sale throws `KoardErrorType.DeviceIntegrityError.TamperDetected` (status code `1002`). Clearing the app's data via Android Settings makes the next enrollment succeed — which points at corrupted local state rather than a genuinely tampered device. ### Cause A partial enrollment can leave the SDK's local preferences (certificates, VAC device ID, auth keys, x-via hint, x-random value) half-written and out of sync with the kernel's internal state. The mismatch surfaces as a false `TamperDetected` on the next transaction. ### Fix Call `clearEnrollmentState()` from a worker thread to wipe the local enrollment preferences, then re-attempt enrollment. The active location is intentionally preserved, and this is a local-only operation — it does **not** contact the backend. ```kotlin lifecycleScope.launch(Dispatchers.IO) { val sdk = KoardMerchantSdk.getInstance() sdk.clearEnrollmentState() sdk.enrollDevice() // or enableNfcTransactionsAsync(...) } ``` Use `clearEnrollmentState()` for half-enrolled recovery. If the device is **fully** enrolled, call `unenrollDevice()` instead: it asks the Tap to Pay Ready app to clean up the enrollment data it holds for your app, then clears the SDK's local state. Local state is flushed either way — if the kernel call fails, the returned string reports the failure but the device can still re-enroll.\ \ **This does not fully deprovision the device on Visa's backend.** Per KiC integration guide §3.4.20 (implementation _optional_), `unenrollDevice()` only cleans up enrollment data on the Tap to Pay Ready app; Visa requires the integrator to _also_ call the backend `manageDevice` API to disable the device, otherwise it keeps being billed. Coordinate that step with Koard support. ## Tap to Pay Is Busy with Another Merchant App ### Symptom A sale fails with `KoardErrorType.KicConnectorError.KernelAppBusyWithAnotherMerchant` (status code `98`). ### Cause Introduced with KiC multi-tenancy, this means a **different** merchant app on the device currently holds the lock on the Visa Tap to Pay Ready kernel service. ### Fix Ask the operator to close the other merchant app (or wait for it to finish its transaction), then retry. Calling `resetKernelService()` will **not** resolve this — the lock is owned by a different process, not your app. ## "SDK Not Initialized" Error ### Symptom ```text IllegalStateException: Instance is null. Did you forget to call initialize? ``` is thrown the first time you call `KoardMerchantSdk.getInstance()`. ### Cause `getInstance()` was called before `KoardMerchantSdk.initialize(...)` completed. ### Fix Initialize the SDK in `Application.onCreate()` on a worker thread (`Dispatchers.IO`) before any code accesses `getInstance()`. See the [Installing the SDK](/docs/setting-up-the-android-sdk/installing-the-sdk) guide for the recommended startup sequence. ## Pre-Flight Checklist Before contacting Koard support about a device that "won't take a tap" or "fails to enroll", confirm **all** of the following on the merchant's device: * Device is running **Android 13 (API 33) or later** * **NFC** is enabled in Settings * **NFC and contactless payments** is enabled (where present) * **Developer mode** is **off** * **Automatic date and time** is **on** * **Google Play Protect** is enabled * **Google Play Services** is installed and up to date * **Visa Tap to Pay Ready** is installed, either directly from Google Play or through the optional in-app flow — see [installation options](/docs/setting-up-the-android-sdk/installing-the-sdk#step-5-install-visa-tap-to-pay-ready) * Device is not rooted and is not running a custom ROM * `checkKiCEligibility()` returns no failures If every item is checked and the device still fails, gather the following before contacting support: * Device make, model, and Android version * **Koard SDK version** (the version of `koard-android` / the Koard Merchant SDK your app is built against) * The `statusCode` and `statusCodeDescription` from the failing `KoardTransactionResponse`, or the `KoardErrorType` from the thrown `KoardException` * A log excerpt from the time of the failure. The SDK logs to Logcat under the fixed tag **`KoardSDK`**; capture it with `adb logcat -s KoardSDK`. Verbosity is set once at startup via `KoardMerchantSdk.initialize(..., logLevel = KoardLogLevel.VERBOSE)` — there is no runtime setter, and the default is `NONE` in release builds, so ask the merchant for a build that opts in. The SDK never logs [API keys](/docs/appendix/glossary#api-key "Secret header credential for REST calls; v5 keys use scoped permissions."), tokens, device certificates, or enrollment key material at any level. # Demo ## Setup To run the demo app, you need to get the project and configure your API credentials: **1. Get the Demo Project** * Clone the Git repository and select the 1.0.7 release branch: `git clone --branch release/1.0.7 https://github.com/koardlabs/koard-android.git` * Or [download the ZIP file](https://github.com/koardlabs/koard-android/archive/refs/heads/release/1.0.7.zip) **2. Open the Project in Android Studio** * Open Android Studio * Select **File > Open** * Navigate to the `koard-android` folder and open it * Wait for Gradle sync to complete The demo app is the root Gradle module. Use the published SDK from **Maven Central** as `com.koard:koard-android-sdk:1.0.7`; no SDK source module, GitHub Packages token, or manually copied AAR is needed. The release branch also carries a local mirror. To resolve from Central, remove its `exclusiveContent` block for `KoardReleaseMirror` in `settings.gradle.kts`, leaving these dependency repositories: ```kotlin dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { google() mavenCentral() } } ``` Keep the separate `pluginManagement` repositories and the demo's other Gradle settings. In `build.gradle.kts`, use `implementation("com.koard:koard-android-sdk:1.0.7")`. **3. Configure Your Credentials** Open `build.gradle.kts` and set your **[API key](/docs/appendix/glossary#api-key "Secret header credential for REST calls; v5 keys use scoped permissions.")** for each build flavor via the `API_KEY` `buildConfigField`. The flavor's `ENVIRONMENT` field selects the matching `KoardEnvironment` at startup: ```kotlin android { flavorDimensions += "environment" productFlavors { create("uat") { dimension = "environment" isDefault = true buildConfigField("String", "ENVIRONMENT", "\"UAT\"") buildConfigField("String", "API_KEY", "\"YOUR_API_KEY\"") applicationIdSuffix = ".uat" } create("prod") { dimension = "environment" buildConfigField("String", "ENVIRONMENT", "\"PROD\"") buildConfigField("String", "API_KEY", "\"YOUR_API_KEY\"") } } } ``` Replace `YOUR_API_KEY` with your Koard API key. The merchant **code** and **PIN** are not build config values — you enter them on the demo's login screen at runtime, where they are passed to `sdk.login(merchantCode, merchantPin)`. The active location is selected in-app after login. **Important**: For production use, externalize these credentials using secure storage or environment variables. Never commit actual credentials to version control. **4. Select Build Flavor** The demo app has two build flavors: | Flavor | API Environment | Application ID Suffix | | ------ | -------------------------------------------------------------------------------------------------------------------------- | --------------------- | | uat | [UAT](/docs/appendix/glossary#uat "Isolated sandbox environment — transactions do not move live money.")/Testing (default) | `.uat` | | prod | Production | (none) | In Android Studio: * Go to **Build > Select Build Variant** * Choose your desired flavor (e.g., `uatDebug` for testing) There is no `dev` flavor. To point the demo at a development backend, switch the SDK to `KoardEnvironment.Custom(...)` in `DemoApplication`. **5. Build the Project** Build the project using one of these methods: **Via Android Studio:** * Click **Build > Make Project** (⌘+F9 / Ctrl+F9) **Via Command Line:** ```bash # Build UAT flavor ./gradlew assembleUatDebug # Build production flavor ./gradlew assembleProdRelease ``` **6. Install on Physical Device** **Physical Device Required**: [Tap to Pay](/docs/appendix/glossary#tap-to-pay-android "Contactless acceptance on NFC Android devices using the Visa Tap to Pay Ready kernel.") on Android requires a physical device with NFC hardware. The emulator does not support NFC payments. **Prerequisites:** * Physical Android device with NFC support (Android 13+) * Tap-to-pay kernel app installed on the device (see the Running Payments guide) * USB debugging enabled on the device **Install the app:** **Via Android Studio:** * Connect your device via USB * Click **Run > Run 'app'** (Shift+F10) **Via Command Line:** ```bash # Install UAT build ./gradlew installUatDebug # Install production build ./gradlew installProdRelease ``` ## Running the Demo Disable Developer Mode **IMPORTANT: Turn Off Developer Mode** After installing or updating the app, disable developer mode **before enrollment, location changes on an enrolled device, or any tap-to-pay transaction**: 1. Go to **Settings > System > Developer Options** 2. Toggle **Developer Mode** to OFF 3. Restart your device (recommended) The SDK rejects enrollment and tap-to-pay transactions while developer mode is enabled. After disabling it, return to the demo and let its readiness checks refresh. **Developer Workflow:** * **Enable Developer Mode** → Install/update app → **Disable Developer Mode** → Log in → Select location → Enroll → Run transactions Launch the App Open the Koard Demo app on your device. You should see the main screen. Authenticate Merchant Enter your merchant **code** and **PIN** on the login screen — these are entered at runtime, not baked into the build — and submit. The demo calls `sdk.login(merchantCode, merchantPin)`. You will know authentication was successful if you see: * The app navigates past the login screen * The location selector and enrollment panel become available Select Location Tap the location selector in the top bar and choose your active location. The demo calls `sdk.setActiveLocation(location.id)` with your choice. The selected location is used for all transactions, and **enrollment cannot proceed without it**. Enroll the Device **Enrollment is not automatic.** Nothing enrolls as a side effect of logging in or picking a location — you must press the button. Until enrollment completes, the demo's home screen shows the enrollment panel instead of the transaction form, and any tap API would throw `KoardErrorType.NotReady.NotEnrolled`. While `readinessState.enrollmentState` is anything other than `Enrolled`, the demo's home screen renders an enrollment panel in place of the transaction form. Press **Enroll Device** to run `sdk.enrollDevice()`. Under the hood the demo: 1. Checks the tap-to-pay kernel app is installed via `sdk.isKernelAppInstalled()`, and surfaces "Visa Kernel app is not installed." if it isn't 2. Calls `sdk.enrollDevice()`, which generates device certificates and enrolls the device with Koard's payment services 3. Treats the returned `String` as an error message if it contains "failed" or "error", and catches `KoardException` for typed failures You will know enrollment was successful if you see: * The enrollment panel is replaced by the transaction form * `readinessState.enrollmentState` becomes `Enrolled` and the reader status banner clears * NFC transactions can be initiated Enrollment is rejected outright if developer mode is on, if no active location is set, or if the device is already enrolled. To re-enroll, use **Unenroll Device** on the Settings screen (which calls `sdk.unenrollDevice()`) first. Process Sample Transaction Test a contactless payment: 1. Tap **Process Transaction** or **New Payment** 2. Enter a sample dollar amount (e.g., $10.00) 3. Select transaction type (Sale or [Preauth](/docs/appendix/glossary#preauth "Authorization hold without capture; must later capture or reverse.")) 4. Tap **Start** or **Continue** Complete Tap to Pay Transaction Complete the payment flow: 1. Prompt the customer to tap their card or phone 2. Hold the card/phone against the device's NFC reader 3. Wait for the transaction to process 4. View the transaction result (approved/declined) Transaction details will include: * Transaction ID * Authorization code * Card type and last 4 digits * Amount charged * Transaction status View Transaction History Navigate to **Transaction History** to: * View all completed transactions * Filter by date or status * View detailed transaction information * Send receipts via email or SMS ## Demo App Features The demo app showcases the following SDK capabilities: ### Merchant Authentication * Login with merchant code and PIN * Session management * Automatic token refresh ### Device Enrollment * NFC configuration with Koard's tap-to-pay services * Certificate management * Device provisioning status ### Location Management * Retrieve all merchant locations * Select active location * Location-based transaction processing ### Transaction Processing * Sale transactions (immediate capture) * Preauthorization (auth hold) * Capture (settle preauth) * Refund processing * Transaction reversal * Tip adjustment ### Transaction History * List all transactions * View transaction details * Filter and search * Transaction status tracking ### Digital Receipts * Send receipts via email * Send receipts via SMS * Receipt customization ## Project Structure The demo app structure: ```plaintext koard-android/ ├── src/ │ └── main/ │ ├── java/com/koard/android/ │ │ ├── DemoApplication.kt # SDK initialization │ │ ├── MainActivity.kt # NFC register/unregister in onResume/onPause │ │ ├── navigation/ # Tab navigation (History, Home, Settings) │ │ └── ui/ │ │ ├── LoginScreen.kt / LoginViewModel.kt │ │ ├── MainScreen.kt / MainScreenViewModel.kt │ │ ├── SettingsScreen.kt │ │ ├── TransactionDetailsScreen.kt │ │ └── TransactionHistoryScreen.kt │ ├── res/ │ │ └── xml/nfc_tech_filter.xml # IsoDep tech filter for TECH_DISCOVERED │ └── AndroidManifest.xml ├── build.gradle.kts # Build configuration + SDK coordinate ├── settings.gradle.kts # Google + Maven Central dependency repositories └── README.md ``` ### SDK Integration The demo uses the released Maven Central artifact after the repository setup above: ```kotlin // build.gradle.kts implementation("com.koard:koard-android-sdk:1.0.7") ``` The published artifact is a **shaded fat AAR**: it bundles the tap-to-pay engine (KiC) and relocates OkHttp, Okio, Retrofit, kotlinx.serialization, and Ktor under `com.koardlabs.*`, so those never clash with your app's own versions. It also means they are **not** transitive dependencies — see [Installing the SDK](/docs/setting-up-the-android-sdk/installing-the-sdk) for what you must declare yourself. The environment (UAT vs. PROD) is chosen at runtime from the flavor's `ENVIRONMENT` `buildConfigField`, not by swapping artifacts: * **UAT builds** → `KoardEnvironment.UAT` * **Prod builds** → `KoardEnvironment.PROD` ### How the Demo Initializes the SDK `DemoApplication.onCreate()` maps `BuildConfig.ENVIRONMENT` to a `KoardEnvironment` and initializes the SDK on a worker thread, passing the API key from `BuildConfig`: ```kotlin val environment = when (BuildConfig.ENVIRONMENT) { "PROD" -> KoardEnvironment.PROD "UAT" -> KoardEnvironment.UAT else -> KoardEnvironment.UAT } KoardMerchantSdk.initialize( application = this@DemoApplication, apiKey = BuildConfig.API_KEY, environment = environment, timeoutSeconds = 30L, logLevel = KoardLogLevel.NONE ) ``` Merchant credentials are supplied later, on the login screen, via `sdk.login(merchantCode, merchantPin)`. To reduce first-tap latency, warm the reader after merchant login, active-location selection, and enrollment by collecting `sdk.prepare()` on a worker thread. Watch for `KoardPrepareStatus.Done` and handle preparation errors. See [Running Payments](/docs/setting-up-the-android-sdk/running-payments) for details. ## Build Commands Reference ```bash # Clean build ./gradlew clean # Build all flavors ./gradlew build # Build specific flavor ./gradlew assembleUatDebug ./gradlew assembleProdRelease # Install on device ./gradlew installUatDebug ./gradlew installProdRelease # Uninstall from device ./gradlew uninstallUatDebug ./gradlew uninstallProd # Run tests ./gradlew test # Generate APK ./gradlew assembleDebug ./gradlew assembleRelease ``` ## Troubleshooting ### Common Issues **App crashes on startup** **Solution**: * Verify credentials are configured correctly in `build.gradle.kts` * Check that you're not using placeholder values (YOUR\_API\_KEY\_HERE) * Review Logcat for specific error messages **"Device not provisioned" error** **Solution**: * Ensure the tap-to-pay kernel app is installed (see the Running Payments guide for setup steps) * Confirm you selected a location and then pressed **Enroll Device** — enrollment never runs on its own * Check NFC hardware is functioning * Verify the kernel app is up to date **NFC not working** **Solution**: * Confirm device has NFC hardware * Enable NFC in device settings * Verify device enrollment was successful * Test with a physical payment card * **IMPORTANT**: Ensure developer mode is DISABLED on the device **"Transaction failed" or "NFC error" during tap to pay** **Solution**: * **Disable developer mode** - This is the most common cause of transaction failures * Go to **Settings > System > Developer Options** and toggle OFF * Restart your device after disabling developer mode * Ensure the tap-to-pay kernel app is running and up to date * Check that NFC is enabled in device settings **Build errors** **Solution**: * Verify JDK 21 is configured * The public demo declares `minSdk = 31`; use a supported Android 13+ device for the reader flow. Your own app can set `minSdk = 33` to enforce that device requirement. * Clean and rebuild: `./gradlew clean build` * Invalidate caches: **File > Invalidate Caches / Restart** **Network errors** **Solution**: * Check device has internet connection * Verify API key and credentials are correct * Ensure you're using the correct build flavor for your environment (uat/prod) ### Debugging Tips **Enable verbose logging:** SDK log verbosity is set **once**, at initialization — there is no runtime setter. Pass a `KoardLogLevel` in `DemoApplication.onCreate()`: ```kotlin import com.koardlabs.merchant.sdk.domain.KoardLogLevel KoardMerchantSdk.initialize( application = this@DemoApplication, apiKey = BuildConfig.API_KEY, environment = environment, timeoutSeconds = 30L, logLevel = KoardLogLevel.VERBOSE ) ``` Levels are `VERBOSE`, `DEBUG`, `INFO`, `WARN`, `ERROR`, and `NONE`. The default is `DEBUG` in debug builds and `NONE` in release, and the level applies in release builds too if you opt in. Secrets — API key and token headers, device certificates, and enrollment key material — are never logged at any level. **Check Logcat:** The SDK logs under the fixed tag `KoardSDK`: ```bash adb logcat -s KoardSDK ``` **Verify SDK version:** Check which SDK version the demo is using: ```bash ./gradlew dependencies | grep koard ``` ## Testing SDK lifecycle and metadata The public 1.0.7 demo provides **Settings → Reset SDK and sign out**. It calls `deinit()`, initializes a fresh instance, reattaches the foreground Activity for NFC, and returns to login. Settings also shows `isInitialized()`. Ordinary Logout preserves enrollment. See [SDK Lifecycle and Deinitialization](/docs/setting-up-the-android-sdk/sdk-lifecycle#verify-the-flow-in-the-demo). Sales, preauthorizations, API refunds, and tap refunds send example metadata from `TransactionMetadata.kt`. SDK logging is explicitly `KoardLogLevel.NONE`; release builds are minified and non-debuggable. To test custom payment context, pass metadata when calling sale, preauth, or refund and verify the stored event through the API. [Transaction Metadata](/docs/setting-up-the-android-sdk/transaction-metadata) includes examples and retrieval steps; the demo receipt is not a metadata viewer. ## Security Notes * Never commit actual credentials to version control * Use environment variables or secure credential management for production * The `build.gradle.kts` credentials are for demo purposes only * Consider using Android Keystore for sensitive data in production apps * Keep the demo app on devices used exclusively for testing ## Updating the SDK The demo currently uses SDK 1.0.7. For a later published release, update the dependency version in `build.gradle.kts`, then clean-rebuild: ```kotlin implementation("com.koard:koard-android-sdk:1.0.7") ``` Keep `google()` and `mavenCentral()` in `settings.gradle.kts` as configured during setup. No local mirror or version filter needs updating. 1.0.7 adds optional metadata parameters to payment methods, changing their JVM signatures. Clean-rebuild the app and any native wrapper. Kotlin source callers can omit metadata; Java callers must pass the added argument (`null` if unused). Amounts remain `Int`. Handle the new `KoardReaderStatus` values or add an `else` branch to exhaustive `when` expressions. ## Next Steps After running the demo app successfully: * [Install SDK in Your App](/docs/setting-up-the-android-sdk/installing-the-sdk) - Integrate SDK into your own application, including login, location selection, and enrollment * [Running Payments](/docs/setting-up-the-android-sdk/running-payments) - Drive tap-to-pay sessions and post-reader operations * [SDK Response Codes](/docs/setting-up-the-android-sdk/sdk-response-codes) - Understand transaction outcomes, error codes, and error handling * [Payment Lifecycle](/docs/payments/payment-lifecycle) - Understand payment flows * [Supported Devices & NFC Tap Location](/docs/setting-up-the-android-sdk/supported-devices) - Device compatibility and where to tap * [Troubleshooting](/docs/setting-up-the-android-sdk/troubleshooting) - Fix enrollment failures and taps that cancel instantly ## Support For technical support or questions: * Email: * Documentation: * GitHub Issues: # Payments Learn how to orchestrate Koard payment flows across sale, preauthorization, capture, adjustment, reversal, and refund operations. ## Available Guides * [Idempotency](/docs/payments/idempotency): Persisting `event_id` and recovering uncertain payment outcomes * [Sale](/docs/payments/methods/sale) – One-step auth + capture transactions * [Preauth](/docs/payments/methods/preauth) – Hold funds before finalizing totals * [Capture](/docs/payments/methods/capture) – Settle preauthorized amounts * [Incremental Auth](/docs/payments/methods/incremental-auth) – Increase an existing authorization * [Tip Adjust](/docs/payments/methods/tip-adjust) – Update gratuity before settlement * [Reverse](/docs/payments/methods/reverse) – Release funds from a [preauth](/docs/appendix/glossary#preauth "Authorization hold without capture; must later capture or reverse.") * [Refund](/docs/payments/methods/refund) – Return captured funds * [Payment Lifecycle](/docs/payments/payment-lifecycle) – End-to-end transaction flow * [Surcharging](/docs/payments/surcharging) - Implementing [Surcharging](/docs/appendix/glossary#surcharge "Fee on eligible credit-card transactions to offset processing costs (not debit).") **Looking for SDK usage?** Start with the [iOS Running Payments guide](/docs/setting-up-the-ios-sdk/running-payments) and pair it with these payment references. # Idempotency and Outcome Recovery Persist a client-generated `event_id` before submitting each payment operation that exposes one. Retain it with your order and the returned `transaction_id`. A new event ID represents a new operation; it is not a way to retry an uncertain payment. ## Identifiers | Identifier | Purpose | |---|---| | `event_id` | Identifies one operation, such as a sale, capture, or refund | | `transaction_id` | Groups the original payment and its follow-up events | Generate a UUID4 for `event_id`. Save it durably before the request leaves the device or backend. Keep the outcome and identifiers for reconciliation after app restarts. If you omit it, Koard can generate an ID, but a lost response then leaves your client without the lookup identifier. SDK arguments vary by released platform version. For example, iOS capture accepts `eventId`, while Android 1.0.7 capture and tip adjustment do not expose that argument. Follow the platform-specific API instead of inventing an argument. ## Duplicate Responses A duplicate event ID is rejected with HTTP 400 and the stable error code `duplicate_event_id`. Look up that event's outcome; do not treat the duplicate error as a decline or generate a new ID to submit again. Branch on `error`, not human-readable `details` text. ## Look Up an Event Your backend can query: ```bash curl 'https://api.uat.koard.com/v1/transactions/event/YOUR_PERSISTED_EVENT_ID' \ -H 'X-Koard-apikey: YOUR_API_KEY' ``` Requires `payments:read` and access to the account owning the event. Keep backend API keys out of shipped apps; expose an authenticated recovery endpoint through your backend if needed. | Response | Action | |---|---| | `200` with a completed operation | Reconcile the transaction status, amounts, and processor response with your order | | `200` with `pending` | Keep the operation unresolved and poll with bounded backoff | | `200` with `surcharge_pending` | Complete the supported cardholder confirmation flow; polling alone does not confirm the surcharge | | `404` | No visible committed event was found. The request may still be in flight, or your key may be querying the wrong account or environment | | Authentication or permission failure | Correct access; do not infer a payment outcome from this response | | Lookup timeout or server error | Keep the outcome unresolved and retry the lookup with backoff | **A 404 does not prove the original POST never reached the processor.** Do not immediately resubmit, switch event IDs, or replay one-time EMV reader data or cryptograms based solely on a missing lookup result. ## Recovery Sequence 1. Save the operation's event ID, order, intended amount, merchant account, location, and environment before submitting. 2. Submit once through the supported SDK or REST endpoint. 3. Inspect a successful response's transaction status and amounts. Pending states, partial approvals, and recorded errors need their appropriate follow-up; HTTP 2xx alone is not enough. 4. For a duplicate event, timeout, lost response, or server error, stop automatic payment retries and look up the saved event ID. 5. Poll with bounded backoff, for example 1, 2, 5, 10, and 30 seconds. Reconcile against your signed webhook deliveries and processor reports when available. 6. If the outcome remains unresolved, retain the identifiers and flag the order for investigation. Start a new reader session or operation only after resolving the previous outcome through the supported recovery process. Duplicate detection is a safeguard, not a promise that concurrent or in-flight retries are safe. Avoid parallel submissions of the same operation. ## Webhook Reconciliation Verify webhook signatures and deduplicate by delivery ID. Transaction bodies include `event_id` and `transaction_id`; store them with the event outcome. A webhook mirror in your backend can support recovery when the app cannot reach Koard, but delayed or missing webhook delivery is not evidence of failure. See [Payment Lifecycle](payment-lifecycle.md), [Available Events](/docs/webhooks/available-events), and [Webhook Setup](/docs/webhooks/setting-up-webhooks). # Tax and Tip Handling Koard resolves **tax** settings hierarchically, and handles **tips** either at the time of sale or as a later adjustment. > [Surcharge](/docs/appendix/glossary#surcharge "Fee on eligible credit-card transactions to offset processing costs (not debit).") settings (`surcharge_rate`, `surcharge_basis`, `surcharge_confirmation_required`) use the same location, terminal, then account resolution order described below. See [Surcharging](/docs/payments/surcharging). ## Hierarchical tax defaults `tax_rate` and `tax_basis` can be configured at **three** levels — the **account**, the **location**, and the **terminal**: | Field | Meaning | | ----------- | ------------------------------ | | `tax_rate` | Tax percentage. | | `tax_basis` | What the tax is calculated on. | **Resolution order:** Koard uses the first non-null setting from the **location**, then the **terminal**, then the merchant **account**. A `null` inherits from the next level. An explicit zero rate stops inheritance and disables the default tax. Set the broad default once on the account. Only set the field on a location or terminal when it needs to differ from the level above it. `tax_rate` and `tax_basis` here are **configuration defaults** (resolved in location → terminal → account order, with an explicit zero stopping inheritance). They are separate from the **transaction-breakdown** fields returned on a completed transaction: `taxAmount` (the computed tax, in **cents**) and `taxRate` (the rate actually applied to that transaction; see the transaction breakdown schema for its exact representation). Don't conflate the config input (`tax_rate`, a percentage) with the computed breakdown output (`taxRate` / `taxAmount`). ## Sending tax you calculated For a basket with multiple product tax rates, send the combined tax in `breakdown.taxAmount` and omit `taxRate` or set it to `null`. A supplied `taxAmount`, including zero, is authoritative: Koard does not recalculate it from a rate or add the configured default tax. Setting account `tax_rate: 0` is compatible with this flow, but is not required to override a default when you supply `taxAmount`. For example, this breakdown requests a $100.00 subtotal and exactly $7.25 tax: ```json { "subtotal": 10000, "taxAmount": 725, "taxRate": null, "tipAmount": 0, "tipType": "fixed" } ``` Pass `amount: 10725` before any surcharge calculated by Koard. The resulting sale reports `subtotal: 10000` and `tax_amount: 725`; an eligible surcharge changes `total_amount`, not those two components. A null request rate is not a promise that the response's `tax_rate` stays null. The released iOS 1.0.21 and Android 1.0.7 `PaymentBreakdown` models accept a nullable `taxRate`. Use `taxRate: nil` in Swift or `taxRate = null` in Kotlin with the explicit `taxAmount`. These are request fields in camelCase; API and webhook transaction fields use `tax_amount` and `tax_rate`. ## Tip handling Tips are captured in one of two modes, tracked on the transaction as `tip_capture_mode`: | Mode | When | Notes | | ------------------------------- | ----------------------------------------------------------------------------- | -------------------------------------------------------------- | | **Tip at sale** (`tip_at_sale`) | The tip is included on the original sale. | Any non-tip-adjust transaction that carries a tip. | | **Tip adjust** (`tip_adjust`) | The tip is added/changed **after** the sale (e.g. restaurant tip-on-receipt). | A dedicated `tip_adjust` transaction referencing the original. | Each carries a `tip_amount` and `tip_type` (e.g. `percentage` or a fixed amount). **Tip adjust does not apply surcharge.** A tip adjustment only changes the tip on an existing transaction — it does not recompute or add a surcharge. Surcharge is applied on the **sale**, independently of tips (see [Surcharging](/docs/payments/surcharging)). # Surcharging Koard supports configured surcharges on eligible credit-card transactions. Eligibility, supported amounts, disclosures, and cardholder confirmation depend on the processor and merchant configuration. Do not apply surcharges to debit cards. Confirm your surcharge rules with your processor before enabling them. ## Configuration Defaults Configure the public surcharge defaults on the merchant account, terminal, or location where your account has update access: | Field | Meaning | | --------------------------------- | ------------------------------------------------------------------------- | | `surcharge_rate` | Whole percent units, for example `3.5` for 3.5% | | `surcharge_basis` | `total`, `subtotal`, `subtotal_plus_tax`, or `subtotal_plus_tip` | | `surcharge_confirmation_required` | Whether the cardholder must confirm a surcharge before the sale continues | The first non-null setting from location, then terminal, then account applies. A null value inherits; zero rate disables the default surcharge. The default basis is `total`, including subtotal, tax, and tip. Do not assume every merchant uses that basis. See [Tax and Tip Handling](/docs/payments/tax-and-tip-handling). ### Calculation and eligibility For a configured default, `surcharge_basis: subtotal` excludes both `taxAmount` and `tipAmount`. The other bases include exactly the components named above. Koard calculates in minor currency units and rounds to the nearest integer using **half-even** rounding: an exact half-cent goes to the even cent. For example, 3% of 150 cents is 4.5 cents and rounds to 4 cents. This describes the server-calculated default; a supplied fixed `surcharge.amount` is already an integer and is not recalculated. The automatic surcharge gate excludes debit cards and cards classified as prepaid or unknown by Koard's BIN data. Eligibility depends on the available card classification, including upstream lookup results; do not use a credit-network brand alone as proof of eligibility. On the Fiserv Nashville Global route, an applied surcharge is included once in the authorized total and also sent in the processor's dedicated surcharge amount field. That field describes a component of the total, not an extra charge on top. ## Request Overrides A breakdown's surcharge object accepts **one of**: ```json {"amount": 451} ``` ```json {"percentage": 3.5} ``` ```json {"bypass": true} ``` `amount` is an integer in minor currency units. `percentage` uses whole percent units, so `3.5` means 3.5%, not `0.035`. Never send both amount and percentage. When `bypass` is true, omit both. For an explicitly calculated fee, include it in the final request total and use the matching fixed surcharge amount. For example, USD subtotal 10000 + tax 875 + tip 2000 gives a base of 12875 cents. At a 3.5% total basis, round the fee to 451 cents and send a total of 13326 cents. **iOS:** ```swift let breakdown = PaymentBreakdown( subtotal: 10000, taxRate: 8.75, taxAmount: 875, tipAmount: 2000, tipType: .fixed, surcharge: PaymentBreakdown.Surcharge(amount: 451) ) let response = try await KoardMerchantSDK.shared.sale( amount: 13326, breakdown: breakdown, currency: CurrencyCode(currencyCode: "USD", displayName: "US Dollar"), eventId: eventId ) ``` On Android, supply `Surcharge(amount = 451)` in the `PaymentBreakdown` passed to the released SDK's sale flow. See the full [Android payment flow](/docs/setting-up-the-android-sdk/running-payments) for Activity, coroutine, and reader-event handling. ## Flow 1: Sale with Cardholder Confirmation When a sale returns `surcharge_pending` (iOS SDK `surchargePending`), show the surcharge disclosure and final amount to the cardholder and complete the supported SDK confirmation flow. Do not mark the sale captured while confirmation is pending. Inspect the confirmation's resulting status and amounts. Do not independently resend the original sale. The confirmation arguments and reader states differ between platforms. Follow the [iOS](/docs/setting-up-the-ios-sdk/running-payments) or [Android](/docs/setting-up-the-android-sdk/running-payments) flow. A preauth does not use this sale confirmation workflow. ### Pending amounts, rejection and expiry When the effective `surcharge_confirmation_required` is true and the calculated fee is positive, an eligible sale returns `surcharge_pending` with `surcharge_amount` and the final `total_amount` already calculated. A zero fee or excluded card does not require surcharge confirmation. For Fiserv Nashville Global, this pending step occurs **before processor authorization**. Accepting through `confirm(..., true)` submits the payment. Declining through `confirm(..., false)` cancels the pending sale with `status: canceled` and `status_reason: surcharge_declined_by_payer`; there is no authorization from this pending step to reverse. The saved confirmation context expires after **120 seconds**. An attempt to accept after expiry returns `status: canceled` with `status_reason: surcharge_expired`. `surcharge_expired` is a reason, not a transaction status. Background cleanup can also mark stale pending sales canceled; the visible state can update later than the 120-second confirmation deadline, so do not depend on a status change at exactly two minutes. On Android, `cancelTransaction()` only cancels the reader session. It does not submit a surcharge decision or reverse a backend transaction. Once you have a `surcharge_pending` transaction, reject it with `sdk.confirm(transactionId = transactionId, confirm = false, eventId = decisionEventId)` and inspect the result. See [Confirming a surcharge](/docs/setting-up-the-android-sdk/running-payments#confirming-a-surcharge). Declining or expiring a pending surcharge emits `transaction.cancel` with the cancellation reason. Use the confirmation response or [cancellation webhook](/docs/webhooks/available-events#surcharge-cancellation) to update checkout, and [outcome recovery](/docs/payments/idempotency) if the response is lost. ## Flow 2: Custom Surcharge via Preauth For a processor-approved custom surcharge flow: 1. Create a preauth with `surcharge: {"bypass": true}` in the breakdown. 2. Inspect the returned `card_type` and other supported eligibility information. The transaction response has **no `bin` property**. If your integration requires an external BIN lookup, use an approved data source; do not infer a BIN from masked card data. 3. For an eligible credit card, calculate the permitted fee using the configured basis and rounding rules. 4. Where supported, increase the hold by the fee through incremental authorization. 5. Capture the final total with a complete breakdown containing the fixed surcharge amount. An incremental authorization request can omit breakdown entirely: ```http POST /v3/payments/{transaction_id}/auth X-Koard-apikey: YOUR_API_KEY Content-Type: application/json ``` ```json {"amount": 451, "event_id": "YOUR_INCREMENT_EVENT_ID"} ``` The legacy incremental-auth breakdown does not accept a nested surcharge object. Android 1.0.7 `incrementalAuth` does not expose a breakdown argument either. Include the final supported breakdown at capture: ```http POST /v4/payments/{transaction_id}/capture X-Koard-apikey: YOUR_API_KEY Content-Type: application/json ``` ```json { "amount": 13326, "event_id": "YOUR_CAPTURE_EVENT_ID", "breakdown": { "subtotal": 10000, "taxAmount": 875, "taxRate": 8.75, "tipAmount": 2000, "tipType": "fixed", "surcharge": {"amount": 451} } } ``` Keep the original preauth, increment, and capture event IDs distinct and persist each before submission. Verify the increment succeeded before capturing an increased amount. ## Remove a Fee Before Capture If a fee is not permitted, remove it from the final breakdown and capture the lower supported amount. Use a surcharge bypass without amount or percentage. Remaining holds are released according to processor and issuer rules; do not promise immediate availability to the cardholder. ## Follow-Up Operations Tip adjustment does not recalculate the original surcharge. Refund and reversal treatment depends on processor rules; do not assume every processor automatically prorates a surcharge on a partial refund. Reconcile the returned amounts and processor report. See [Capture](/docs/payments/methods/capture), [Incremental Auth](/docs/payments/methods/incremental-auth), [Tip Adjust](/docs/payments/methods/tip-adjust), [Refund](/docs/payments/methods/refund), and [Idempotency](/docs/payments/idempotency). # Sale A sale authorizes and captures a payment in a single step—use it when the final amount is known at checkout. ## Prerequisites * Authenticated merchant with `login()` * Active location selected in app settings via `setActiveLocationID()` (iOS) or `setActiveLocation()` (Android) * Account linking and reader preparation completed on iOS, or location selection, enrollment, and reader preparation completed on Android Persist `eventId` before submission. Create Android payment flows on `Dispatchers.IO`, apply `flowOn(Dispatchers.IO)`, and collect from a Main/UI coroutine. Handle the full reader event flow described in the [Android guide](/docs/setting-up-the-android-sdk/running-payments). Use the `handleTransactionEvent` implementation in [Android Running Payments](/docs/setting-up-the-android-sdk/running-payments). It handles progress, failure, pending or partial results, surcharge confirmation, and final approval or decline. The UI callbacks run on the main dispatcher. ## Basic Sale **iOS:** ```swift let breakdown = PaymentBreakdown( subtotal: 10000, // $100.00 in cents taxRate: 8.75, // 8.75% as a percent value taxAmount: 875, // $8.75 in cents tipAmount: 2000, // $20.00 tip tipType: .fixed ) let currency = CurrencyCode(currencyCode: "USD", displayName: "US Dollar") let response = try await KoardMerchantSDK.shared.sale( amount: 12875, // subtotal + tax + tip breakdown: breakdown, currency: currency, eventId: eventId ) ``` **Android:** ```kotlin val breakdown = PaymentBreakdown( subtotal = 10000, taxRate = 8.75, taxAmount = 875, tipAmount = 2000, tipType = "fixed" ) val flow = withContext(Dispatchers.IO) { sdk.sale( activity = activity, amount = 12875, breakdown = breakdown, eventId = eventId ) }.flowOn(Dispatchers.IO) flow.collect { event -> handleTransactionEvent(event) } ``` ## Sale with Surcharge For a configured `total` surcharge basis, the [surcharge](/docs/appendix/glossary#surcharge "Fee on eligible credit-card transactions to offset processing costs (not debit).") percentage is applied to the **full transaction amount** (subtotal + tax + tip). Other configured bases differ. This example uses a fixed surcharge amount: ```plaintext surcharge = (subtotal + taxAmount + tipAmount) × (surchargePercentage / 100) = (10000 + 875 + 2000) × (3.5 / 100) = 451 cents ($4.51) ``` **iOS:** ```swift let breakdown = PaymentBreakdown( subtotal: 10000, taxRate: 8.75, taxAmount: 875, tipAmount: 2000, tipType: .fixed, surcharge: PaymentBreakdown.Surcharge( amount: 451 // surcharge on full amount ) ) let response = try await KoardMerchantSDK.shared.sale( amount: 13326, // 12875 + 451 surcharge breakdown: breakdown, currency: currency, eventId: eventId ) ``` **Android:** ```kotlin val breakdown = PaymentBreakdown( subtotal = 10000, taxRate = 8.75, taxAmount = 875, tipAmount = 2000, tipType = "fixed", surcharge = Surcharge( amount = 451 ) ) val flow = withContext(Dispatchers.IO) { sdk.sale( activity = activity, amount = 13326, breakdown = breakdown, eventId = eventId ) }.flowOn(Dispatchers.IO) flow.collect { event -> handleTransactionEvent(event) } ``` ## Surcharge Confirmation When the configured sale flow requires cardholder surcharge confirmation, the response is `surchargePending` on iOS or `surcharge_pending` in REST. Present the disclosure and final amount, then confirm or decline through the supported SDK flow. Do not assume every surcharged sale requires confirmation. **iOS:** ```swift if let transaction = response.transaction, transaction.status == .surchargePending { let disclosure = response.transaction?.surchargeDisclosure ?? "" let approved = await showSurchargeDisclosure(disclosure) let confirmed = try await KoardMerchantSDK.shared.confirm( transaction: transaction.transactionId, confirm: approved, amount: nil, breakdown: nil, eventId: confirmationEventId ) renderChargeResult(confirmed) // Inspect the returned KoardTransaction } ``` **Android:** See the current Android reader and confirmation flow in the [Android Running Payments guide](/docs/setting-up-the-android-sdk/running-payments). ## Bypassing Automatic Surcharge Set `bypass: true` to skip the processor's automatic surcharge. Useful for [custom BIN-based surcharging](/docs/payments/surcharging#bin-based-custom-surcharge): **iOS:** ```swift let breakdown = PaymentBreakdown( subtotal: 10000, taxRate: 8.75, taxAmount: 875, tipType: .fixed, surcharge: PaymentBreakdown.Surcharge(bypass: true) ) ``` **Android:** ```kotlin val breakdown = PaymentBreakdown( subtotal = 10000, taxRate = 8.75, taxAmount = 875, tipType = "fixed", surcharge = Surcharge(bypass = true) ) ``` ## Parameters | Parameter | Type | Required | Description | | ----------- | ------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | `amount` | `Int` | Yes | Total amount in minor units (cents) | | `breakdown` | `PaymentBreakdown?` | No | Itemized breakdown (see below) | | `currency` | `CurrencyCode` | Yes (iOS) | Currency for the transaction | | `eventId` | `String?` | No | [Idempotency](/docs/appendix/glossary#idempotency "Safe retry pattern using event_id so network failures do not double-charge.") key (UUID recommended) | | `activity` | `Activity` | Yes (Android) | Android activity for NFC access | ### PaymentBreakdown | Field | Type | Description | | ----------- | ------------ | ---------------------------------------------------------------------- | | `subtotal` | `Int` | Base amount in minor units | | `taxRate` | `Double?` | Tax rate as a percent value (`8.75` = 8.75%) | | `taxAmount` | `Int` | Calculated tax in minor units | | `tipAmount` | `Int?` | Tip in minor units | | `tipRate` | `Double?` | Tip rate as decimal (alternative to fixed tip) | | `tipType` | `TipType` | `.fixed` / `.percentage` (iOS) or `"fixed"` / `"percentage"` (Android) | | `surcharge` | `Surcharge?` | Nested surcharge object | ### Surcharge | Field | Type | Default | Description | | ------------ | --------- | ------- | --------------------------------------------------------------------------------- | | `amount` | `Int?` | `nil` | Fixed surcharge in minor units | | `percentage` | `Double?` | `nil` | Whole percent units: `3.5` means 3.5%. Applied to the configured surcharge basis. | | `bypass` | `Bool` | `false` | Skip automatic surcharge calculation | ## See Also * [Preauth](/docs/payments/methods/preauth) — Hold funds now, capture later * [Surcharging](/docs/payments/surcharging) — Automatic and custom surcharge workflows * [Payment Lifecycle](/docs/payments/payment-lifecycle) — End-to-end payment flow # Preauth A preauthorization places a hold on the cardholder's funds without capturing. Use it when the final amount may change (e.g., tips, adjustments, custom [surcharging](/docs/appendix/glossary#surcharge "Fee on eligible credit-card transactions to offset processing costs (not debit).")). ## Prerequisites * Authenticated merchant with `login()` * Active location selected in app settings via `setActiveLocationID()` (iOS) or `setActiveLocation()` (Android) * Account linking and reader preparation completed on iOS, or location selection, enrollment, and reader preparation completed on Android Persist `eventId` before submission. Create Android payment flows on `Dispatchers.IO`, apply `flowOn(Dispatchers.IO)`, and collect from a Main/UI coroutine. Handle the full reader event flow described in the [Android guide](/docs/setting-up-the-android-sdk/running-payments). Use the `handleTransactionEvent` implementation in [Android Running Payments](/docs/setting-up-the-android-sdk/running-payments). It handles progress, failure, pending or partial results, surcharge confirmation, and final approval or decline. The UI callbacks run on the main dispatcher. ## Basic Preauth **iOS:** ```swift let breakdown = PaymentBreakdown( subtotal: 10000, taxRate: 8.75, taxAmount: 875, tipType: .fixed ) let currency = CurrencyCode(currencyCode: "USD", displayName: "US Dollar") let response = try await KoardMerchantSDK.shared.preauth( amount: 10875, breakdown: breakdown, currency: currency, eventId: eventId ) try handleTransactionResponse(response) // Handler in iOS Running Payments ``` **Android:** ```kotlin val breakdown = PaymentBreakdown( subtotal = 10000, taxRate = 8.75, taxAmount = 875, tipType = "fixed" ) val flow = withContext(Dispatchers.IO) { sdk.preauth( activity = activity, amount = 10875, breakdown = breakdown, eventId = eventId ) }.flowOn(Dispatchers.IO) flow.collect { event -> handleTransactionEvent(event) } ``` ## Preauth with Surcharge Bypass To calculate surcharges yourself (e.g., [BIN-based](/docs/appendix/glossary#bin "Bank Identification Number (card prefix) used for surcharging and some VAR sheets.") logic), bypass the processor's automatic surcharge: **iOS:** ```swift let breakdown = PaymentBreakdown( subtotal: 10000, taxRate: 8.75, taxAmount: 875, tipAmount: 2000, tipType: .fixed, surcharge: PaymentBreakdown.Surcharge(bypass: true) ) let response = try await KoardMerchantSDK.shared.preauth( amount: 12875, // subtotal + tax + tip (no surcharge yet) breakdown: breakdown, currency: currency, eventId: eventId ) ``` **Android:** ```kotlin val breakdown = PaymentBreakdown( subtotal = 10000, taxRate = 8.75, taxAmount = 875, tipAmount = 2000, tipType = "fixed", surcharge = Surcharge(bypass = true) ) val flow = withContext(Dispatchers.IO) { sdk.preauth( activity = activity, amount = 12875, breakdown = breakdown, eventId = eventId ) }.flowOn(Dispatchers.IO) flow.collect { event -> handleTransactionEvent(event) } ``` After the preauth is authorized, use the returned card type and your configured eligibility rules to calculate any permitted custom surcharge, then apply it via [incremental auth](/docs/payments/methods/incremental-auth). The public response does not expose a BIN. If your approved workflow uses BIN classification, obtain it through that integration. Never apply a surcharge to debit cards. See the [custom surcharge workflow](/docs/payments/surcharging#bin-based-custom-surcharge) for the full flow. ## Parameters | Parameter | Type | Required | Description | | ----------- | ------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | `amount` | `Int` | Yes | Hold amount in minor units (cents) | | `breakdown` | `PaymentBreakdown?` | No | Itemized breakdown | | `currency` | `CurrencyCode` | Yes (iOS) | Currency for the transaction | | `eventId` | `String?` | No | [Idempotency](/docs/appendix/glossary#idempotency "Safe retry pattern using event_id so network failures do not double-charge.") key (UUID recommended) | | `activity` | `Activity` | Yes (Android) | Android activity for NFC access | ## After Preauth A preauth hold must be followed by one of: | Action | Description | | ----------------------------------------------------------- | --------------------------------------- | | [Capture](/docs/payments/methods/capture) | Finalize at the same or lower amount | | [Incremental Auth](/docs/payments/methods/incremental-auth) | Increase the hold (e.g., add surcharge) | | [Tip Adjust](/docs/payments/methods/tip-adjust) | Update the tip before capture | | [Reverse](/docs/payments/methods/reverse) | Void the hold entirely | ## See Also * [Sale](/docs/payments/methods/sale) — One-step authorize + capture * [Capture](/docs/payments/methods/capture) — Finalize a preauth * [Surcharging](/docs/payments/surcharging) — Bypass and custom surcharge workflows # Capture Capture finalizes a previously authorized ([preauth](/docs/payments/methods/preauth)) transaction. You can capture at the original amount, a lower amount (partial capture), or with an updated breakdown that includes tip or [surcharge](/docs/appendix/glossary#surcharge "Fee on eligible credit-card transactions to offset processing costs (not debit)."). Run Android reference operations from `withContext(Dispatchers.IO)`. The examples unwrap `Result` with `getOrThrow()`; handle thrown errors at the UI boundary. Inspect the returned transaction status and operation amounts before displaying success. See the [Android operation helpers](/docs/setting-up-the-android-sdk/running-payments#post-reader-operations). ## Prerequisites * An existing [preauth](/docs/appendix/glossary#preauth "Authorization hold without capture; must later capture or reverse.") transaction ID * Transaction must be in an authorized (uncaptured) state ## Full Capture **iOS:** ```swift let response = try await KoardMerchantSDK.shared.capture( transactionId: transactionId, amount: 10875 ) ``` **Android:** ```kotlin val transaction = sdk.capture( transactionId = transactionId, amount = 10875 ).getOrThrow() ``` ## Capture with Updated Breakdown Include the final breakdown when the tip or surcharge changed after the preauth. If the final amount exceeds the current authorization, obtain any required [incremental authorization](/docs/payments/methods/incremental-auth) first. Each surcharge specifies an amount or a percentage, never both: **iOS:** ```swift let finalBreakdown = PaymentBreakdown( subtotal: 10000, taxRate: 8.75, taxAmount: 875, tipAmount: 3000, // customer added a $30 tip tipType: .fixed, surcharge: PaymentBreakdown.Surcharge( amount: 486 // 3.5% of (10000 + 875 + 3000) = 486 ) ) let response = try await KoardMerchantSDK.shared.capture( transactionId: transactionId, amount: 14361, // 10000 + 875 + 3000 + 486 breakdown: finalBreakdown ) ``` **Android:** ```kotlin val finalBreakdown = PaymentBreakdown( subtotal = 10000, taxRate = 8.75, taxAmount = 875, tipAmount = 3000, tipType = "fixed", surcharge = Surcharge( amount = 486 ) ) sdk.capture( transactionId = transactionId, amount = 14361, breakdown = finalBreakdown ).getOrThrow() ``` ## Partial Capture Capture at a lower amount than the original hold: **iOS:** ```swift let response = try await KoardMerchantSDK.shared.capture( transactionId: transactionId, amount: 8000 // capture $80 of a $108.75 hold ) ``` **Android:** ```kotlin val transaction = sdk.capture( transactionId = transactionId, amount = 8000 ).getOrThrow() ``` > Any remaining authorization hold is released according to the processor and issuer rules; do not promise an immediate release. ## Parameters | Parameter | Type | Required | Description | | -------------------------------------------- | ------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------ | | `transactionId` | `String` | Yes | The preauth transaction to capture | | `amount` | `Int?` | No | Capture amount in minor units. Defaults to the original preauth amount. | | `breakdown` | `PaymentBreakdown?` | No | Updated breakdown with final tip/surcharge | | `eventId` (iOS only in the current releases) | `String?` | No | [Idempotency](/docs/appendix/glossary#idempotency "Safe retry pattern using event_id so network failures do not double-charge.") key | ## See Also * [Preauth](/docs/payments/methods/preauth) — Place a hold before capturing * [Incremental Auth](/docs/payments/methods/incremental-auth) — Increase the hold before capture * [Tip Adjust](/docs/payments/methods/tip-adjust) — Update tip before capture * [Surcharging](/docs/payments/surcharging) — Include surcharge in capture breakdown --- title: Incremental Auth --- # Incremental Auth Incremental authorization increases the hold on an existing [preauth](preauth.md), where the processor and merchant configuration support it. `amount` is the additional amount to authorize in minor currency units, not the new total. Run Android reference operations from `withContext(Dispatchers.IO)`. The examples unwrap `Result` with `getOrThrow()`; handle thrown errors at the UI boundary. Inspect the returned transaction status and operation amounts before displaying success. See the [Android operation helpers](../../android-sdk/details/running-payments.md#post-reader-operations). ## Increase a Hold **iOS 1.0.21:** ```swift let response = try await KoardMerchantSDK.shared.auth( transactionId: transactionId, amount: 2000, // Increase the hold by $20.00 eventId: eventId // Persist before submitting this operation ) ``` **Android 1.0.7:** ```kotlin val transaction = sdk.incrementalAuth( transactionId = transactionId, amount = 2000, eventId = eventId ).getOrThrow() ``` Android 1.0.7 does not accept a `breakdown` argument on `incrementalAuth`. Inspect the returned transaction before proceeding to capture. ## REST ```http POST /v3/payments/{transaction_id}/auth X-Koard-apikey: YOUR_API_KEY Content-Type: application/json ``` ```json {"amount": 2000, "event_id": "YOUR_PERSISTED_EVENT_ID"} ``` Requires `payments:incremental-auth`. Omit `breakdown` when increasing the hold alone. This endpoint's optional legacy breakdown does not accept a nested surcharge object. Supply the final supported breakdown when [capturing](capture.md). ## Custom Surcharges For an eligible credit-card preauth, an approved custom surcharge flow can increase the hold by the surcharge amount before capture. Confirm eligibility from the returned card information and your processor's rules. The transaction response does not expose a `bin` property; do not assume one exists or use masked card data as a substitute for a verified BIN lookup. See [Surcharging](surcharging.md#bin-based-custom-surcharge). ## See Also - [Preauth](preauth.md) - [Capture](capture.md) - [Idempotency](idempotency.md) --- title: Tip Adjust --- # Tip Adjust Tip adjust changes gratuity on an existing transaction before settlement, where the processor and merchant configuration support it. Use the transaction ID returned by the original payment. Availability and timing depend on the processor's settlement cutoff. Run Android reference operations from `withContext(Dispatchers.IO)`. The examples unwrap `Result` with `getOrThrow()`; handle thrown errors at the UI boundary. Inspect the returned transaction status and operation amounts before displaying success. See the [Android operation helpers](../../android-sdk/details/running-payments.md#post-reader-operations). ## Fixed Tip **iOS 1.0.21:** ```swift let response = try await KoardMerchantSDK.shared.tipAdjust( transactionId: transactionId, amount: 3000, // $30.00 tip, not the new transaction total tipType: .fixed, eventId: eventId // Persist before submitting this operation ) ``` **Android 1.0.7:** ```kotlin import com.koardlabs.merchant.sdk.domain.AmountType val transaction = sdk.adjust( transactionId = transactionId, type = AmountType.FIXED, amount = 3000 ).getOrThrow() ``` Inspect the returned transaction and processor response before marking the adjustment successful. The Android method is `adjust`; the iOS method is `tipAdjust`. Android 1.0.7 does not expose an `eventId` argument for `adjust`. ## REST ```http POST /v1/payments/{transaction_id}/adjust X-Koard-apikey: YOUR_API_KEY Content-Type: application/json ``` For a fixed tip: ```json {"type": "fixed", "amount": 3000, "event_id": "YOUR_PERSISTED_EVENT_ID"} ``` For a percentage tip: ```json {"type": "percentage", "percentage": 18, "event_id": "YOUR_PERSISTED_EVENT_ID"} ``` REST `percentage` is an integer from 1 through 100: `18` means 18%, not `0.18`. A fixed `amount` must be a positive integer in minor currency units. Requires `payments:tipadjust`. ## Surcharge Behavior Tip adjustment does not recalculate the existing surcharge. If the final breakdown changes, use the supported [capture](capture.md) flow and verify the processor's authorization limits before increasing the final amount. ## See Also - [Preauth](preauth.md) - [Capture](capture.md) - [Idempotency](idempotency.md) # Reverse (Void) A reverse voids a transaction **before** settlement, requesting release of the authorization hold according to the processor and issuer rules. Use it to cancel a sale or [preauth](/docs/appendix/glossary#preauth "Authorization hold without capture; must later capture or reverse.") that hasn't settled yet. > For returning funds **after** settlement, see [Refund](/docs/payments/methods/refund). Persist a separate `eventId` with your order before each operation below, and pass that saved value to the SDK. On a timeout or lost response, reconcile that ID instead of generating a new one. See [Idempotency and Outcome Recovery](/docs/payments/idempotency). Run Android reference operations from `withContext(Dispatchers.IO)`. The examples unwrap `Result` with `getOrThrow()`; handle thrown errors at the UI boundary. Inspect the returned transaction status and operation amounts before displaying success. See the [Android operation helpers](/docs/setting-up-the-android-sdk/running-payments#post-reader-operations). ## Full Reverse **iOS:** ```swift let response = try await KoardMerchantSDK.shared.reverse( transactionId: transactionId, eventId: eventId ) ``` **Android:** ```kotlin val transaction = sdk.reverse( transactionId = transactionId, eventId = eventId ).getOrThrow() ``` ## Partial Reverse Reduce the authorized amount without voiding the entire transaction: **iOS:** ```swift let response = try await KoardMerchantSDK.shared.reverse( transactionId: transactionId, amount: 5000, // reduce hold by $50 eventId: eventId ) ``` **Android:** ```kotlin val transaction = sdk.reverse( transactionId = transactionId, amount = 5000, eventId = eventId ).getOrThrow() ``` ## Surcharge Handling When reversing a surcharged transaction, the [surcharge](/docs/appendix/glossary#surcharge "Fee on eligible credit-card transactions to offset processing costs (not debit).") is handled according to the processor's reversal rules. Verify the returned reversed amount, especially for partial reversals. ## When to Use Reverse vs. Refund | | Reverse | Refund | | ---------------- | ----------------------------------------- | ------------------------------------------------- | | **Timing** | Before settlement | After settlement | | **Availability** | Hold release timing depends on the issuer | Credit timing depends on the processor and issuer | | **Surcharge** | Verify the returned reversal amount | Verify the processor's refund treatment | | **Use case** | Cancel, customer changed mind | Post-settlement return | ## Parameters | Parameter | Type | Required | Description | | --------------- | --------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------ | | `transactionId` | `String` | Yes | Transaction to reverse | | `amount` | `Int?` | No | Partial reverse amount in minor units. Omit for full void. | | `eventId` | `String?` | No | [Idempotency](/docs/appendix/glossary#idempotency "Safe retry pattern using event_id so network failures do not double-charge.") key | ## See Also * [Refund](/docs/payments/methods/refund) — Return funds after settlement * [Sale](/docs/payments/methods/sale) — Original payment * [Preauth](/docs/payments/methods/preauth) — Authorization hold # Refund A refund returns funds on an existing captured or settled payment where the processor supports it. Availability before settlement and supported refund amounts depend on the merchant configuration and processor rules. > For voiding a transaction **before** settlement, see [Reverse](/docs/payments/methods/reverse). Persist a separate `eventId` with your order before each operation below, and pass that saved value to the SDK. On a timeout or lost response, reconcile that ID instead of generating a new one. See [Idempotency and Outcome Recovery](/docs/payments/idempotency). Run Android reference operations from `withContext(Dispatchers.IO)`. The examples unwrap `Result` with `getOrThrow()`; handle thrown errors at the UI boundary. Inspect the returned transaction status and operation amounts before displaying success. See the [Android operation helpers](/docs/setting-up-the-android-sdk/running-payments#post-reader-operations). ## Full Refund **iOS:** ```swift let response = try await KoardMerchantSDK.shared.refund( transactionId: transactionId, amount: 12875, eventId: eventId ) ``` **Android:** ```kotlin val transaction = sdk.refund( transactionId = transactionId, amount = 12875, eventId = eventId ).getOrThrow() ``` ## Partial Refund Refund a portion of the original transaction: **iOS:** ```swift let response = try await KoardMerchantSDK.shared.refund( transactionId: transactionId, amount: 5000, // refund $50 of the original eventId: eventId ) ``` **Android:** ```kotlin val transaction = sdk.refund( transactionId = transactionId, amount = 5000, eventId = eventId ).getOrThrow() ``` ## Tap-Based Refund (iOS) For card-present refunds where the customer taps their card: ```swift let response = try await KoardMerchantSDK.shared.refund( transactionId: transactionId, amount: 12875, eventId: eventId, withTap: true ) ``` > Tap-based refunds require the card reader to be prepared. The customer taps their card to confirm the refund. ## Tap-Based Refund (Android) Use the Activity-based refund overload from a UI coroutine, with SDK work on IO: ```kotlin val flow = withContext(Dispatchers.IO) { sdk.refund( activity = activity, transactionId = transactionId, amount = 12875, withTap = true, eventId = eventId ) }.flowOn(Dispatchers.IO) flow.collect { response -> handleTransactionEvent(response) // Handler in Android Running Payments } ``` `refundEmv(...)` is also supported as a tap-refund alias. Reference-based `sdk.refund` does not require an Activity or a new card tap. Wrap invocation and collection in the [Running Payments](/docs/setting-up-the-android-sdk/running-payments) error-handling pattern. ## Surcharge Handling When refunding a surcharged transaction, the [surcharge](/docs/appendix/glossary#surcharge "Fee on eligible credit-card transactions to offset processing costs (not debit).") is handled according to the processor's refund rules. Do not assume every processor prorates it automatically. Specify the intended refund amount and reconcile the returned refund and surcharge amounts. ### Fiserv Nashville Global refund breakdown The `amount` you request is the **total to return**, including any tax and surcharge portion. Koard does not add another surcharge refund on top. For this route, Koard calculates the refund event's breakdown from the original payment; it does not rely on the processor to return that split. Read the event with `transaction_type: refund` and the matching `event_id` in the operation response or `transaction.refund` webhook: | Field | Meaning on the refund event | | ------------------------ | -------------------------------------------- | | `total_amount` | Amount returned by this refund | | `surcharge_amount` | Surcharge portion of this refund | | `tax_amount` | Tax portion of this refund | | `subtotal`, `tip_amount` | Remaining components of this refund | | `refunded` | Cumulative amount refunded across operations | These component fields describe the refund event, not the original sale. The SDK properties are `surchargeAmount` and `taxAmount`. A later transaction lookup can return a newer event, so retain the refund's `event_id` and use history or event lookup when reconciling it. The surcharge portion is `round_half_even(original_surcharge * refund_total / original_total)`, using the fee actually collected. Tax, tip and subtotal divide the remaining amount proportionally, with cent remainders allocated so the components sum to the refund total. The final refund returns any remaining surcharge cents from prior rounding. For example, a payment with $100 subtotal, $10 tax, $20 tip and a $3 subtotal-based surcharge totals $133. A $66.50 refund returns $50 subtotal, $5 tax, $10 tip and **$1.50 surcharge**. ## Parameters | Parameter | Type | Required | Description | | --------------- | ----------------------------------------------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | `transactionId` | `String` | Yes | Original transaction to refund | | `amount` | `Int?` for reference refunds; `Int` for Android tap refunds | Required for tap refunds | Minor currency units; omit or pass null/nil for a full reference refund | | `eventId` | `String?` | No | [Idempotency](/docs/appendix/glossary#idempotency "Safe retry pattern using event_id so network failures do not double-charge.") key | | `withTap` | iOS `Bool`, Android `Boolean` | For the Android Activity overload | Pass true for a card-present refund; iOS defaults to false | | `activity` | `Activity` | For Android tap refunds | Activity for a card-present refund; not required for reference-based `refund` | ## See Also * [Reverse](/docs/payments/methods/reverse) — Void before settlement * [Sale](/docs/payments/methods/sale) — Original payment * [Payment Lifecycle](/docs/payments/payment-lifecycle) — End-to-end flow # Payment Lifecycle Koard facilitates card acceptance and routes payment requests to the merchant's configured processor. A payment can contain several events: the original sale or preauth, followed by capture, tip adjustment, reversal, or refund. ## Before Taking a Payment Authenticate the merchant, load its locations, and select the active location. **Provide a location selector in your app's settings.** The location's linked terminal determines the processor configuration and merchant/terminal identifiers used for that merchant. Show the selected location to the operator and block payments until a valid location is selected. See [Active location and terminal routing](/docs/getting-started-with-koard/setting-up-the-merchant#active-location-and-terminal-routing). Prepare the reader using the relevant [iOS](/docs/setting-up-the-ios-sdk/running-payments) or [Android](/docs/setting-up-the-android-sdk/running-payments) guide. Persist an `event_id` before each operation that exposes one. ## Operations | Operation | iOS SDK | Android SDK | REST follow-up | |---|---|---|---| | [Sale](sale.md) | `sale` | `sale` | Initiation is handled by the reader SDK | | [Preauth](preauth.md) | `preauth` | `preauth` | Initiation is handled by the reader SDK | | [Capture](capture.md) | `capture` | `capture` | `POST /v3/payments/{transaction_id}/capture` | | [Incremental auth](incremental-auth.md) | `auth` | `incrementalAuth` | `POST /v3/payments/{transaction_id}/auth` | | [Tip adjust](tip-adjust.md) | `tipAdjust` | `adjust` | `POST /v1/payments/{transaction_id}/adjust` | | [Reverse](reverse.md) | `reverse` | `reverse` | `POST /v1/payments/{transaction_id}/reverse` | | [Refund](refund.md) | `refund`, optionally `withTap: true` | Reference `refund`, or Activity-based `refund(..., withTap = true)` / `refundEmv` | `POST /v1/payments/{transaction_id}/refund` for a reference refund | The released SDKs do not expose identical arguments for every operation. Use each platform's example. Initial card-present requests use SDK-generated encrypted reader data; a PAN or a generic test-card number cannot replace that data. ## Transaction Identifiers - `transaction_id` groups a payment's lifecycle and is used for follow-up operations. - `event_id` identifies an individual operation. Retain it with your order so an uncertain outcome can be recovered. - `GET /v1/transactions/event/{event_id}` reads the event visible to your account using an API key with `payments:read`. An HTTP success alone does not establish that funds were captured. Inspect `status`, the authorized amount, and the processor response, including partial approvals. ## REST Status Values | Status | Meaning | |---|---| | `pending` | Outcome or external input is still pending | | `surcharge_pending` | Cardholder surcharge confirmation is required | | `authorized` | Funds are held; capture or reverse as appropriate | | `captured` | Sale or capture succeeded; settlement follows | | `settled` | Settlement was recorded | | `declined` | Payment was declined | | `error` | An error was recorded; reconcile any uncertain processor outcome | | `refunded` | A refund event succeeded; inspect amounts for partial refunds | | `reversed` | A reversal event succeeded | | `canceled` | The flow was canceled | These are REST values. SDK enums can use platform-specific names, such as iOS `surchargePending`, and include reader or UI states. A typical preauth progresses from `authorized` to `captured` and then `settled`. A sale reaches `captured` directly. Refunds and reversals are separate events on the original transaction; use event history and amounts when reconciling. ## Amounts and Breakdowns Use integer minor currency units for amounts, for example `1000` for USD $10.00. Keep the final subtotal, tax, tip, and surcharge consistent with the total sent to the processor. For a surcharge, specify **one of** `amount` or `percentage`, or use `bypass: true` without either. Percentage surcharge values use whole percent units, for example `3.5` for 3.5%. Fixed surcharge amounts avoid ambiguity when you have already calculated the fee. REST tip adjustment is different: its percentage is an integer from 1 through 100. If the final capture exceeds the current authorized amount, obtain any required incremental authorization first. Processor and card rules govern supported adjustments and settlement cutoffs. ## Recovering an Uncertain Outcome A timeout, lost response, or server error can occur after the processor receives a payment. Do not start a second sale with a new event ID to compensate. Look up the persisted event ID, reconcile the outcome, and follow [Idempotency](idempotency.md). A lookup returning 404 alone does not prove that an in-flight request never reached the processor. ## Webhooks and Settlement Subscribe to the operation event names documented in [Available Events](/docs/webhooks/available-events): `transaction.create`, `transaction.authorize`, `transaction.sale`, `transaction.capture`, `transaction.increment`, `transaction.tip_adjust`, `transaction.cancel`, `transaction.reverse`, and `transaction.refund`. The webhook body is the transaction object; delivery metadata is provided in the webhook headers. Verify signatures and deduplicate deliveries. Event names identify operations, so also inspect the transaction status and amounts. Settlement is reported separately through [batch APIs and events](/docs/batch-and-settlements/running-batches). The supported Fiserv integration manages batches automatically; customers must not manually open, close, or edit those batches. # Batch and Settlements Learn how to manage batch processing and settlement operations with Koard's payment platform. [Get started with Koard](/docs/introduction) Koard provides comprehensive batch processing and settlement management for transactions processed through our system. Whether you're an enterprise [ISV](/docs/appendix/glossary#isv "Software company building payment or POS applications on Koard."), [PSP](/docs/appendix/glossary#psp "Company that serves multiple merchants with payment processing."), or building a payment platform, Koard offers flexible solutions to meet your settlement needs. **What you learn** In this guide, you'll learn: * How Koard's batch processing system works * The two main workflows for different business types * How to retrieve batch information and settlement data * Best practices for enterprise vs. platform implementations * How to integrate with Koard's settlement APIs ## Before you begin This guide covers batch processing and settlement management in Koard. For a better understanding of how to process payments, see our [Running Payments guide](/docs/setting-up-the-ios-sdk/running-payments). If you're ready to start accepting payments, see our [Getting Started guide](/docs/introduction). ## Batch Processing Overview Koard can run batches and settlements for transactions processed through our system. Our platform handles all scheduling and retries automatically, providing you with: * **Batch Lists**: Retrieve comprehensive batch information * **Batch Components**: Detailed breakdown of batch contents * **[Settlement](/docs/appendix/glossary#settlement "Transfer of funds from the payment processor to the merchant account after batching.") Breakdown**: Success and failure analysis * **Automated Scheduling**: Koard handles all timing and retries * **Per-Merchant Batching**: Batches are created per unique [MID](/docs/appendix/glossary#mid "Processor-assigned merchant identifier used for routing and batching.") and [TID](/docs/appendix/glossary#tid "Processor-assigned per-terminal identifier; batches are typically unique per MID/TID.") combination ## Two Main Workflows Koard supports two distinct workflows based on your business model and technical requirements. ### Enterprise ISVs and PSPs **Recommended approach**: Use your own batching solution For enterprise ISVs and PSPs with existing batching infrastructure, we recommend maintaining your own batch processing and settlement management: * **Append to Existing Batches**: Add Koard transactions to your already open batches * **Trigger Batch Closure**: Close batches using your existing system * **Inform Koard**: Update Koard's systems when settlements occur * **Avoid Duplicate Issues**: Prevents duplicate batch IDs and synchronization problems **Important**: For TSYS, Fiserv, and Elavon processors, there's a high risk of duplicate batch IDs, out-of-order synchronization, and missing/invalid tags that may fail batches when using Koard's batching system. **Exception**: If you've created specific MID and TID combinations for [Tap to Pay](/docs/appendix/glossary#tap-to-pay "Apple contactless acceptance on iPhone without extra hardware.") through Koard, you can safely use Koard's batching system without the above concerns. ### Everyone Else **Recommended approach**: Use Koard's settlement system If Koard is your sole authorization layer and you don't have existing batching infrastructure: * **Full Settlement Management**: Let Koard handle all batch processing * **Automated Scheduling**: Koard manages timing and retries * **Simplified Integration**: Single API for all settlement operations * **Comprehensive Reporting**: Built-in analytics and monitoring ## Key Features | Feature | Description | Enterprise ISVs | Platform Users | | ------------------------- | ------------------------------------- | ------------------------- | --------------------------- | | **Batch Creation** | Automatic batch creation per MID/TID | Use your own system | Koard handles automatically | | **Settlement Scheduling** | Automated timing and retries | Your existing schedule | Koard manages timing | | **Error Handling** | Robust retry logic and error recovery | Your error handling | Koard handles retries | | **Reporting** | Batch and settlement analytics | Your reporting system | Koard provides reports | | **API Access** | RESTful APIs for batch management | Limited to status updates | Full API access | ## Getting Started 1. **Determine Your Workflow**: Choose between enterprise or platform approach 2. **Set Up Integration**: Configure your chosen workflow 3. **Test Batch Processing**: Verify your implementation 4. **Monitor Operations**: Track batch and settlement status ## Best Practices ### For Enterprise ISVs and PSPs * **Maintain Existing Batches**: Don't create separate batches for Koard transactions * **Sync Settlement Data**: Keep Koard informed of settlement status * **Handle Edge Cases**: Implement proper error handling for processor-specific issues * **Monitor for Duplicates**: Watch for duplicate batch IDs across systems ### For Platform Users * **Use Koard APIs**: Leverage Koard's comprehensive batch management * **Monitor Settlement Status**: Track batch processing through Koard's dashboard * **Implement [Webhooks](/docs/appendix/glossary#webhook "HTTPS POST callback from Koard when account or payment events occur.")**: Set up real-time notifications for settlement events * **Regular Reconciliation**: Verify settlement data against your records ## Integration Points * **API Integration**: RESTful APIs for batch management and status updates * **Webhook Notifications**: Real-time batch and settlement status updates * **Dashboard Monitoring**: Visual tracking of batch processing and settlements * **Reporting Tools**: Comprehensive analytics and settlement reports ## See also This wraps up the batch and settlements overview. See the links below for detailed implementation guides: * [Running Batches](/docs/batch-and-settlements/running-batches) - Step-by-step batch implementation * [Running Payments](/docs/setting-up-the-ios-sdk/running-payments) - Payment processing fundamentals * [Setting up Webhooks](/docs/webhooks/setting-up-webhooks) - Configure webhooks to receive real-time batch and settlement event notifications ## Processor-specific batch management TSYS and Elavon support manual terminal-capture batches. Worldpay supports manual and scheduled release of existing batches but does not support explicit batch opening. The managed Fiserv integration runs automatically and does not support manual batch operations. * [Running Batches](/docs/batch-and-settlements/running-batches) * [Fiserv Automatic Settlement](/docs/batch-and-settlements/fiserv-settlement) # Running Batches Learn how to create, manage, and process batches with Koard's payment platform. [Get started with Koard](/docs/introduction) Koard provides comprehensive batch processing capabilities for managing transactions and settlements. Whether you choose to let Koard handle batches automatically or manage them yourself, this guide covers all the essential operations and best practices. **What you learn** In this guide, you'll learn: * How to choose between Koard-managed and self-managed batch processing * Core batch operations: opening, closing, and editing batches * Required fields for self-managed batch processing * [Webhook](/docs/appendix/glossary#webhook "HTTPS POST callback from Koard when account or payment events occur.") integration for real-time batch updates * Processor-specific considerations and constraints * Best practices for batch management and error handling ## Before you begin This guide covers batch processing operations in Koard. For a better understanding of batch concepts, see our [Batch and Settlements overview](/docs/batch-and-settlements/overview). If you're ready to start processing payments, see our [Running Payments guide](/docs/setting-up-the-ios-sdk/running-payments). ## Processor Capabilities | Configuration | Manual operations | Scheduling | | -------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------- | | TSYS terminal capture | Open, close, and edit supported batches | Editable terminal batch schedule | | Elavon terminal capture | Open, close, and edit supported batches | Editable terminal batch schedule | | Worldpay | Manual close/release of an existing open batch; explicit open is not supported | Scheduled release is supported; Worldpay creates batches | | Managed Fiserv integration | No manual open, close, or edit | Automatic fixed cycle; monitor through webhooks and read APIs | Use the selected terminal's supported configuration. The presence of a general batch endpoint does not mean every processor supports that operation. See [Fiserv automatic settlement](/docs/batch-and-settlements/fiserv-settlement) and [Automated Batch Scheduling](/docs/batch-and-settlements/automated-batch-scheduling). ## Two Batch Processing Approaches Koard supports two distinct approaches for batch processing based on your technical requirements and business model. ### Via Koard (Recommended) **Best for**: [Tap to Pay](/docs/appendix/glossary#tap-to-pay "Apple contactless acceptance on iPhone without extra hardware.") terminals with specific gateway configurations Koard can handle batches entirely for a [MID](/docs/appendix/glossary#mid "Processor-assigned merchant identifier used for routing and batching.")/[TID](/docs/appendix/glossary#tid "Processor-assigned per-terminal identifier; batches are typically unique per MID/TID.") combination. This approach is recommended if you have specific terminals configured on the gateway for Tap to Pay. **Key benefits:** * **Automatic Management**: Koard handles all batch operations automatically * **Status Tracking**: Automatic transaction status tracking and reconciliation * **Error Handling**: Built-in retry logic and error recovery * **Webhook Integration**: Real-time updates for all batch events ### Self-Managed Batches **Best for**: Enterprise [ISVs](/docs/appendix/glossary#isv "Software company building payment or POS applications on Koard.") and [PSPs](/docs/appendix/glossary#psp "Company that serves multiple merchants with payment processing.") with existing batch infrastructure If you prefer to manage batches yourself, you'll need to handle the core batch operations and maintain the required transaction data. **Key requirements:** * **Required Fields**: Maintain all necessary transaction data * **Webhook Integration**: Process transaction events for batch management * **Sequential Ordering**: Maintain proper batch number sequencing * **Error Handling**: Implement retry logic for failed operations ## Core Batch Operations Koard provides several core functions that you can apply to batches and transactions: | Operation | Description | When to Use | API Endpoint | | ------------------- | ------------------------------------------------------------ | ------------------------------------------ | ----------------------------------- | | **List Batches** | Retrieve paginated list of batches | Viewing batch history, filtering by status | `GET /v1/batches` | | **Download Report** | Get a temporary download URL for an available managed report | Reconciliation | `GET /v1/batches/{batch_id}/report` | | **Get Batch** | Retrieve specific batch details | View batch status and transactions | `GET /v1/batches/{batch_id}` | | **Open Batch** | Create a new batch for transactions | Start of business day or when needed | `POST /v1/batches/open` | | **Close Batch** | Finalize and submit batch for processing | End of business day or when ready | `POST /v1/batches/{batch_id}/close` | | **Edit Batch** | Add or remove transactions from open batch | Before closing the batch | `PUT /v1/batches/{batch_id}/edit` | ### Listing Batches ```bash GET /v1/batches?limit=50&offset=0&statuses=open ``` **Query Parameters:** * `account_id` (optional): Filter by owning account * `terminal_id` (optional): Filter by terminal ID * `statuses` (optional): Filter by batch lifecycle statuses — accepts multiple values (open, closed, submitted, accepted, partially\_accepted, rejected, cancelled) * `processor_config_id` (optional): Filter by processor config ID * `limit` (optional): Maximum items per page (1-500, default: 50) * `offset` (optional): Number of items to skip (default: 0) ### Getting a Batch ```bash GET /v1/batches/{batch_id}?include_transactions=true ``` **Query Parameters:** * `include_transactions` (optional): If true, embed transactions with the batch (default: false) ### Opening a Batch ```bash POST /v1/batches/open { "terminal_id": "tid_987654321", "processor_batch_id": "batch_001" } ``` **Required Fields:** * `terminal_id`: The terminal identifier * `processor_batch_id` (optional): Assigned processor batch number override where supported. For TSYS, Koard computes the number (001 through 999) and ignores a supplied override. ### Closing a Batch ```bash POST /v1/batches/{batch_id}/close ``` No request body is required. The batch must be in an `open` status, and the configuration must support manual close/release. This operation is unavailable for the managed Fiserv integration. The response body is a Batch object for all processor settlement outcomes: HTTP 200 for acceptance, 409 for a duplicate batch response, and 502 for a non-accepted processor response. Inspect `status`, `response`, and `rejection_reason`; these 409/502 responses are not the usual error envelope. Reconcile before retrying a submission. ### Editing an Open Batch Only open batches can be edited. ```bash PUT /v1/batches/{batch_id}/edit { /*"added_transactions": ["17279734-fa7b-4f26-a945-19a8a98b6258"], "removed_transactions": [],*/ "processor_batch_id": "6" } ``` **Body Fields (all optional):** * `processor_batch_id`: The processor batch ID * `added_transactions`: List of transaction IDs to add to the batch * `removed_transactions`: List of transaction IDs to remove from the batch ## Self-Managed Batch Requirements If you choose to run batches yourself, you'll need to maintain these essential fields for each transaction: ### Required Transaction Fields | Field | Description | Example | | -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | | **Approval Code** | Authorization approval code | `123456` | | **Response Code** | Transaction response code | `00` | | **Transaction Identifier** | Unique transaction ID | `txn_abc123def456` | | **Local Date/Time** | Transaction timestamp | `2024-01-15T14:30:00Z` | | **Amounts** | Auth, settled, tip, [surcharge](/docs/appendix/glossary#surcharge "Fee on eligible credit-card transactions to offset processing costs (not debit)."), cashback | `{"auth": 1000, "settled": 1000}` | | **ACI/Void/Reversal Indicators** | Transaction type indicators | `{"aci": "Y", "void": "N"}` | ### Webhook Integration Every transaction processed through Koard triggers a webhook event. Upon delivery, you'll receive: * **Transaction Data**: All required fields for batch processing * **TLV Tags**: General TLV tags from the payment device * **Status Information**: Real-time transaction status updates * **Batch Context**: Information about which batch the transaction belongs to ```json { "event": "transaction.processed", "data": { "transactionId": "txn_abc123def456", "approvalCode": "123456", "responseCode": "00", "amount": { "auth": 1000, "settled": 1000, "tip": 0 }, "timestamp": "2024-01-15T14:30:00Z", "batchId": "batch_xyz789", "tlvTags": { "aci": "Y", "void": "N" } } } ``` ## Batch Lifecycle Management ### Batch Statuses A batch progresses through several statuses during its lifecycle: | Status | Description | Transitions To | | ----------------------- | -------------------------------------------------- | --------------------------------------- | | **open** | Batch is open and accepting transactions | closed | | **closed** | Batch has been closed and submitted for settlement | submitted, rejected | | **submitted** | Batch has been submitted to the processor | accepted, partially\_accepted, rejected | | **accepted** | Batch has been fully accepted by the processor | (terminal state) | | **partially\_accepted** | Some transactions were accepted, others rejected | (terminal state) | | **rejected** | Batch was rejected by the processor | (terminal state) | | **cancelled** | Batch was cancelled before settlement | (terminal state) | ### Automatic Transaction Addition When a batch is open on a terminal: * **Captured Transactions**: Automatically added to the open batch * **Refunds**: Automatically added to the open batch * **Real-time Updates**: Webhook events provide immediate status updates ### Batch Closure Process 1. **Close at Any Time**: Batches can be closed at any point (typically end of business) 2. **Constraint**: You can only close an open batch 3. **Rejected Batches**: If a batch is rejected or partially approved: * Add all rejected transactions to a new open batch * Close the new batch again * Maintain batch number sequential order ### Editing Open Batches You can edit an open batch by: * **Adding Transactions**: Include new transactions before closing * **Removing Transactions**: Remove transactions if needed * **Constraint**: Only open batches can be edited ## Processor-Specific Considerations ### TSYS Constraints TSYS has specific batch ID requirements that affect batch management: | Constraint | Description | Impact | | ------------------ | --------------------------------- | ------------------------------------ | | **Batch ID Range** | Numbered batch IDs from 1-999 | Limited batch ID availability | | **Sliding Window** | 5-day sliding window for reuse | Batch IDs can be reused after 5 days | | **Uniqueness** | Unique per MID/TID combination | Prevents duplicate batch IDs | | **Error Handling** | Reusing batch ID results in error | Requires proper ID management | ### Batch ID Management * **Default Behavior**: Koard automatically configures batch IDs * **Override Option**: You can override batch IDs when running your own batches * **Webhook Integration**: Set batch ID via webhook when managing batches yourself * **Sequential Order**: Maintain proper batch number sequencing ### Splitting Batches **Not Recommended**: Splitting batches is not recommended due to various constraints imposed by processors like TSYS, Fiserv, and Elavon. **Best Practice**: Each MID/TID should have one batch per day to avoid potential duplicate batch ID issues. ## Error Handling and Recovery ### Failed/Rejected Batches When batches fail or are rejected: 1. **Status Information**: Koard returns detailed status information 2. **Failure Details**: Specific information about what failed 3. **Retry Logic**: Automatic retry mechanisms for recoverable errors 4. **Manual Approval**: Rejected batches can be manually approved via the portal on TSYS ### Monitoring and Alerts * **Real-time Status**: Monitor batch processing status in real-time * **Error Notifications**: Receive alerts for batch failures * **Webhook Events**: Subscribe to batch status change events * **Dashboard Monitoring**: Visual tracking of batch operations ## Best Practices ### Batch Timing * **End of Business**: Close batches at the end of each business day * **Peak Hours**: Avoid processing batches during peak transaction times * **Timezone Considerations**: Consider your merchant's timezone for batch windows * **Business Hours**: Process batches during business hours for faster support ### Error Handling * **Retry Logic**: Implement retry logic for failed batch operations * **Partial Failures**: Handle cases where some transactions in a batch fail * **Monitoring**: Set up alerts for batch processing failures * **Recovery Procedures**: Have clear procedures for handling rejected batches ### Performance Optimization * **Batch Size**: Optimize batch sizes for your transaction volume * **Sequential Processing**: Maintain proper batch number sequencing * **Resource Management**: Monitor system resources during batch processing * **Webhook Processing**: Ensure reliable webhook event processing ## See also This wraps up the running batches guide. See the links below for related information: * [Batch and Settlements Overview](/docs/batch-and-settlements/overview) - High-level batch concepts * [Running Payments](/docs/setting-up-the-ios-sdk/running-payments) - Payment processing fundamentals * [Setting up Webhooks](/docs/webhooks/setting-up-webhooks) - Webhook configuration * [Available Events](/docs/webhooks/available-events) - Webhook event reference # Automated Batch Scheduling ## Automated Batch Scheduling Koard supports automated batch close or release scheduling per terminal for TSYS, Elavon, and Worldpay. TSYS and Elavon support explicit batch opening; Worldpay creates batches through its host-capture flow. The supported Fiserv integration uses a fixed processor-managed cycle. ### Supported Processors | Processor | Supported | Notes | | -------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **TSYS** | ✅ | Full support. Batch numbers auto-managed (001-999). | | **Elavon** | ✅ | Full support via ViaConex TC 920/921/929. | | **Worldpay** | ✅ | Scheduled batch release through the settlement interface; explicit open is unavailable. | | Fiserv managed integration | Automatic, fixed | Koard tracks the processor-managed cycle; customers cannot change the schedule or manually open, close, or edit batches. See [Fiserv settlement](/docs/batch-and-settlements/fiserv-settlement). | | Payroc | ❌ | Not supported for automated scheduling. | ### How It Works 1. **Configure a schedule** on the terminal via `PUT /v2/terminals/{terminal_id}` 2. **Koard's scheduler** runs every minute, checking for terminals due for batch close 3. When due, Koard **closes or releases the current batch**. A new batch is opened where the processor supports explicit open; Worldpay manages batch creation. 4. If a close fails, Koard **retries up to 10 times** with backoff 5. On persistent failure, a **webhook error event** is sent ### Setting Up a Schedule Add a `batch_schedule` field to your terminal update: ```json PUT /v2/terminals/{terminal_id} { "batch_schedule": { "timezone": "US/Eastern", "is_active": true, "schedule": [ { "day": "MON", "times": ["23:00"] }, { "day": "TUE", "times": ["23:00"] }, { "day": "WED", "times": ["23:00"] }, { "day": "THU", "times": ["23:00"] }, { "day": "FRI", "times": ["23:00"] }, { "day": "SAT", "times": ["23:00"] } ] } } ``` #### Response The terminal response includes confirmation that the schedule was saved: ```json { "terminal_id": "term-abc123", "name": "Front Counter POS", "mid": "886000001130", "tid": "00000001", "processor_config_id": "cfg-tsys-001", "status": "active", "var_sheet": { "applicationId": "B001" } } ``` ### Schedule Format #### Days Use 3-letter day codes: | Code | Day | | ----- | --------- | | `MON` | Monday | | `TUE` | Tuesday | | `WED` | Wednesday | | `THU` | Thursday | | `FRI` | Friday | | `SAT` | Saturday | | `SUN` | Sunday | #### Times Times are in 24-hour `HH:MM` format. You can set **multiple closes per day**: ```json { "day": "WED", "times": ["12:00", "18:00", "23:00"] } ``` This closes the batch at noon, 6pm, and 11pm on Wednesdays. #### No Close on a Day Simply omit the day from the schedule. If Saturday and Sunday are not listed, no batch close happens on weekends. ### Timezones **DST vs Fixed Timezones**: Choose carefully between DST-aware and fixed-offset timezones. Most merchants want DST-aware timezones so the batch close follows "wall clock" time. #### DST-Aware Timezones (Recommended) These follow daylight saving time transitions. `23:00 US/Eastern` means 11pm EDT in summer and 11pm EST in winter. | Timezone | Description | | ------------- | -------------------------- | | `US/Eastern` | Eastern Time (New York) | | `US/Central` | Central Time (Chicago) | | `US/Mountain` | Mountain Time (Denver) | | `US/Pacific` | Pacific Time (Los Angeles) | | `US/Alaska` | Alaska Time | | `US/Hawaii` | Hawaii Time (no DST) | | `US/Arizona` | Arizona Time (no DST) | You can also use full IANA zone names like `America/New_York`, `America/Chicago`, etc. #### Fixed-Offset Timezones These **never** change for DST. Use only if you want a fixed UTC offset year-round. | Timezone | UTC Offset | Notes | | -------- | ------------- | ------------------------------------ | | `EST` | UTC-5 always | Does **not** switch to EDT in summer | | `MST` | UTC-7 always | Does **not** switch to MDT in summer | | `HST` | UTC-10 always | Same as US/Hawaii | | `UTC` | UTC+0 always | Universal Coordinated Time | #### Example: DST Impact A batch close at `23:00 US/Eastern`: * **Winter (EST)**: Fires at 04:00 UTC * **Summer (EDT)**: Fires at 03:00 UTC A batch close at `23:00 EST`: * **Always**: Fires at 04:00 UTC (even in summer when "wall clock" Eastern time is EDT) ### Managing Schedules #### Update an Existing Schedule ```json PUT /v2/terminals/{terminal_id} { "batch_schedule": { "timezone": "US/Pacific", "schedule": [ { "day": "MON", "times": ["22:00"] }, { "day": "FRI", "times": ["14:00", "22:00"] } ] } } ``` #### Pause Scheduling (Keep Config) Send the current timezone and schedule with `is_active: false` to pause an editable schedule. This object uses create/update defaults, so sending only `is_active` can replace the timezone and schedule with defaults. Preserve the full configuration when pausing: ```json PUT /v2/terminals/{terminal_id} { "batch_schedule": { "timezone": "US/Pacific", "is_active": false, "schedule": [{ "day": "MON", "times": ["22:00"] }] } } ``` #### Resume Scheduling ```json PUT /v2/terminals/{terminal_id} { "batch_schedule": { "timezone": "US/Pacific", "is_active": true, "schedule": [ { "day": "MON", "times": ["23:00"] }, { "day": "TUE", "times": ["23:00"] } ] } } ``` **Important**: An active schedule must have at least one day with times configured. Setting `is_active: true` with an empty schedule will return an error. #### Remove Schedule Entirely (Back to Manual) Set `batch_schedule` to `null`: ```json PUT /v2/terminals/{terminal_id} { "batch_schedule": null } ``` After removal, the terminal returns to manual batch management. #### Updates Without batch\_schedule Updating other terminal fields (name, [MID](/docs/appendix/glossary#mid "Processor-assigned merchant identifier used for routing and batching."), var\_sheet, etc.) does **not** affect the schedule: ```json PUT /v2/terminals/{terminal_id} { "name": "New Terminal Name" } ``` The existing batch schedule is preserved. ### Switching Between Auto and Manual #### Switching from Auto to Manual To switch a terminal back to manual batch management, remove the schedule: ```json PUT /v2/terminals/{terminal_id} { "batch_schedule": null } ``` **Important**: When switching to manual, the current open batch stays open. You are now responsible for: * Closing or releasing batches manually (`POST /v1/batches/{batch_id}/close`) * For TSYS and Elavon, opening new batches manually (`POST /v1/batches/open`) * For Worldpay, allowing the processor to create the next batch; do not call the open endpoint * Managing future batch closes or releases after removing the Koard schedule #### Switching from Manual to Auto Enable a schedule on an existing terminal: ```json PUT /v2/terminals/{terminal_id} { "batch_schedule": { "timezone": "US/Central", "is_active": true, "schedule": [ { "day": "MON", "times": ["22:00"] }, { "day": "TUE", "times": ["22:00"] }, { "day": "WED", "times": ["22:00"] }, { "day": "THU", "times": ["22:00"] }, { "day": "FRI", "times": ["22:00"] } ] } } ``` If the terminal already has an open batch, the scheduler closes or releases it at the next scheduled time. TSYS and Elavon then open a new batch; Worldpay creates its next batch through the host-capture flow. ### Closing a Batch Early For TSYS, Elavon, and Worldpay, you can close or release an eligible batch early while automated scheduling is enabled. This does not apply to the fixed cycle managed by Fiserv: ```bash POST /v1/batches/{batch_id}/close ``` **TSYS and Elavon require a new open batch after an early close.** Call `POST /v1/batches/open` before the next payment. **Worldpay does not support explicit batch opening**; its processor creates the next batch when the next transaction is authorized. Do not call the open endpoint for Worldpay. For **TSYS and Elavon**, when the scheduler fires later at its scheduled time: * If it finds an **open batch with transactions**, it closes and reopens normally * If it finds an **open batch with no transactions** (e.g., you just opened it), it cancels the empty batch and opens a fresh one * If it finds **no open batch** (e.g., you closed early and didn't reopen), it opens a new one For TSYS and Elavon, the scheduler can recover a missing open batch on its next run, but payments remain blocked until a batch is open. For Worldpay, continue taking payments through host capture and release the resulting batch at the configured time; no open request is required. #### TSYS or Elavon Example: Early Close at 3pm, Scheduled Close at 11pm 1. **3:00 PM** — You close the batch early via API 2. **3:01 PM** — You open a new batch via API 3. **3:01 PM – 11:00 PM** — Transactions accumulate in the new batch 4. **11:00 PM** — Scheduler fires, closes the batch (with transactions), opens a new one If you forget to reopen at step 2, the scheduler at 11pm will detect no open batch and open one for you — but any transactions between 3:01 PM and 11:00 PM will have failed because there was no open batch. ### TSYS-Specific Behavior #### Batch Number Auto-Management TSYS batch numbers must be between **001-999** and cannot be reused within **5 consecutive days**. Koard handles this automatically: 1. **On open**: Queries the last closed batch for the terminal and increments 2. **On close**: Verifies no conflicting batch number, auto-increments if needed 3. **On duplicate (QD)**: Automatically retries with the next batch number (up to 10 attempts) #### Batch Number Wrapping When the batch number reaches 999, it wraps around to 001. ### Error Handling | Scenario | Koard's Response | | -------------------------------- | -------------------------------------------------------------------- | | Processor unavailable | Retries up to 10 times with backoff | | Duplicate batch number (TSYS QD) | Auto-increments and retries | | All retries exhausted | Sends `batch.rejected` webhook, logs error | | No open batch to close | Skips close; opens a batch for processors that support explicit open | ### Webhook Events When using automated scheduling, you'll receive the standard batch webhook events: | Event | When | | ----------------- | ----------------------------------------------------------------------- | | `batch.submitted` | Batch sent to processor | | `batch.accepted` | Processor accepted the batch | | `batch.rejected` | Processor rejected the batch, or the scheduler failed after all retries | | `batch.opened` | A new batch is explicitly opened, such as for TSYS or Elavon | ### Permissions The following roles can view and manage batch schedules: | Role | View | Create/Edit | Delete | | ---------------------------------------------------------------------------------------------------- | ---- | ----------- | ------ | | [PSP](/docs/appendix/glossary#psp "Company that serves multiple merchants with payment processing.") | ✅ | ✅ | ✅ | | Partner | ✅ | ✅ | ✅ | | Merchant | ✅ | ✅ | ✅ | ### See also * [Batch and Settlements Overview](/docs/batch-and-settlements/overview) - Batch concepts * [Running Batches](/docs/batch-and-settlements/running-batches) - Manual batch management * [Setting up Webhooks](/docs/webhooks/setting-up-webhooks) - [Webhook](/docs/appendix/glossary#webhook "HTTPS POST callback from Koard when account or payment events occur.") configuration # Fiserv Automatic Batches and Settlement For Koard's supported managed Fiserv integration, batches are managed automatically. Captured sale/capture events and successful refunds are associated with the relevant batch. Authorization-only holds are not captured settlement amounts. **Manual opening, closing, and editing are not supported for this integration.** Those requests return HTTP 400. Do not send manual batch operations or change, disable, or remove the managed schedule. Use the read APIs and webhooks to monitor the cycle. This guide describes the managed Fiserv configuration supplied by Koard. Batch behavior belongs to the selected processor configuration; do not assume every Fiserv configuration has the same capture or scheduling capabilities. ## Settlement Cycle The managed cycle uses `America/Los_Angeles` time with a 02:00 cutoff and submission tracking at 02:30. Transactions after the cutoff belong to the next cycle. The cycle follows Pacific time, including daylight-saving transitions; do not implement a fixed UTC offset in your integration. Koard records submission separately from report reconciliation. `submitted` does not mean the processor has accepted every record or that the merchant has been funded. Empty cycles are canceled without inventing a processor submission or acceptance event. ## Subscribe to Batch Webhooks Configure your endpoint through the [webhook portal](/docs/webhooks/setting-up-webhooks). Subscribe to: | Event | Meaning | Action | |---|---|---| | `batch.submitted` | The non-empty batch reached submission tracking | Save the batch ID and monitor reconciliation | | `batch.accepted` | The processor report confirms acceptance | Reconcile totals; acceptance alone does not confirm merchant funding | | `batch.partially_accepted` | The report includes accepted and rejected records | Inspect details and investigate rejected records | | `batch.rejected` | The report confirms rejection | Investigate the batch and processor report | The HTTP body is a **flat Batch object**, not an `{event, data}` wrapper. Delivery identifiers and signature metadata are in the `svix-*` headers. Subscribe to the relevant event types in the portal, verify signatures, acknowledge quickly, and deduplicate using `svix-id`. See [Available Events](/docs/webhooks/available-events#batch-events). Example subset of a `batch.submitted` body: ```json { "id": "YOUR_BATCH_ID", "account_id": "YOUR_MERCHANT_ACCOUNT_ID", "terminal_id": "YOUR_TERMINAL_ID", "processor_name": "fiserv", "processor_config_id": "YOUR_PROCESSOR_CONFIG_ID", "status": "submitted", "captured_amount": 12875, "refunded_amount": 2000, "transaction_count": 2, "processor_batch_id": "YOUR_PROCESSOR_BATCH_ID", "closed_at": "2026-10-10T09:00:00Z" } ``` Amounts are integer minor currency units. Use `id` as the Koard batch ID. The processor batch identifier is separate. Webhook deliveries can be delayed or arrive out of order; retrieve the latest batch state rather than assuming the last delivered event is the newest state. ## Read Batches and Transactions Requires `batches:read` and access to the owning merchant account. ```bash curl 'https://api.uat.koard.com/v1/batches?account_id=YOUR_MERCHANT_ACCOUNT_ID&limit=50&offset=0' \ -H 'X-Koard-apikey: YOUR_API_KEY' curl 'https://api.uat.koard.com/v1/batches/YOUR_BATCH_ID?include_transactions=true' \ -H 'X-Koard-apikey: YOUR_API_KEY' ``` List responses contain `batches`, `total`, `limit`, `offset`, and `page`. Batch details include status, captured/refunded totals, processor identifiers, and any rejection reason. With `include_transactions=true`, each transaction includes lifecycle history and batch-specific refund attribution. Use `batch_refunded_amount` for refunds attributed to this batch, rather than treating lifecycle-wide `refunded` as this batch's total. ## Download the Processor Report ```bash curl 'https://api.uat.koard.com/v1/batches/YOUR_BATCH_ID/report' \ -H 'X-Koard-apikey: YOUR_API_KEY' ``` When the managed report is archived, the response contains a temporary `url` and `expires_in: 300`. Download the report promptly. Do not log or publish the signed URL. A 404 means no managed report is available. Retry with backoff only when the batch is expected to have a managed report and reconciliation is pending. A batch without a managed report will continue to return 404. Report availability does not confirm funding. Compare the report, batch totals, and your transaction ledger. Flag partial acceptance, rejection, or an unresolved submitted batch for investigation. See [Running Batches](running-batches.md) for other processors. # Setting up Webhooks Learn how to configure and use webhooks to receive real-time updates from Koard's payment platform. [Get started with Koard](/docs/introduction) Koard allows you to add URLs that receive POST requests when specific events occur in your payment system. Each endpoint can be configured to receive a specific set of events, enabling real-time integration with your applications. **What you learn** In this guide, you'll learn: * How to set up webhook endpoints in Koard * How to configure event subscriptions and filters * How to test and verify webhook functionality * How to monitor webhook delivery and troubleshoot issues * Best practices for webhook security and reliability ## Before you begin This guide covers webhook configuration and management in Koard. For a better understanding of available events, see our [Available Events guide](/docs/webhooks/available-events). If you're ready to start processing payments, see our [Running Payments guide](/docs/setting-up-the-ios-sdk/running-payments). ## What are Webhooks? [Webhooks](/docs/appendix/glossary#webhook "HTTPS POST callback from Koard when account or payment events occur.") are HTTP callbacks that Koard sends to your application when specific events occur. This allows you to receive real-time notifications about payment events, transaction updates, and other important changes without constantly polling our APIs. **Key benefits:** * **Real-time Updates**: Get instant notifications about payment events * **Automated Processing**: Trigger automated workflows based on events * **Reduced Polling**: Eliminate the need to constantly poll for updates * **Better User Experience**: Provide immediate feedback to users ## Setting Up Webhook Endpoints ### Access the Developer Portal To set up a new webhook endpoint, navigate to one of the following URLs based on your environment: | Environment | URL | | ------------------------------------------------------------------------------------------------------------ | ------------------------------------- | | **[UAT](/docs/appendix/glossary#uat "Isolated sandbox environment — transactions do not move live money.")** | | | **Production** | | ![webhooks-1](/webhooks-1.png) _Koard Developer Portal - Webhook Configuration_ ### Create a New Endpoint 1. **Navigate to Add Endpoint**: Click "Add Endpoint" on the right side of the developer portal page 2. **Configure HTTPS Endpoint**: Enter your HTTPS endpoint URL that will receive POST requests 3. **Select Events**: Choose which events you want to subscribe to 4. **Save Configuration**: Complete the setup process ![webhooks-2](/webhooks-2.png) _Adding a new webhook endpoint in the Koard Developer Portal_ **Important**: Currently, webhooks can only be managed via the Portal, but API management is on the roadmap for creating and managing endpoints and event filters. ### Webhook Endpoint Requirements Your webhook endpoint must meet these requirements: * **HTTPS Only**: All webhook endpoints must use HTTPS * **POST Method**: Endpoints must accept POST requests * **JSON Payload**: Events are sent as JSON in the request body * **Quick Response**: Return a 2xx status code quickly (within 15 seconds; preferably acknowledge immediately and queue processing) ## Webhook Configuration ### Event Selection Choose which events you want to receive based on your integration needs. If you don't specify any event types, by default your endpoint will receive all events, regardless of type. This can be helpful for getting started and testing, but we recommend selecting specific events for production. | Event Category | Description | Common Events | | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Transaction Events** | Transaction lifecycle events | `transaction.create`, `transaction.authorize`, `transaction.sale`, `transaction.capture`, `transaction.increment`, `transaction.tip_adjust`, `transaction.reverse`, `transaction.cancel`, `transaction.refund` | | **Account Events** | Account management events | `account.created`, `account.updated`, `account.blocked`, `account.unblocked`, `account.deleted` | | **Terminal Events** | Terminal configuration events | `terminal.created`, `terminal.updated`, `terminal.blocked`, `terminal.unblocked`, `terminal.deleted` | | **Location Events** | Location management events | `location.created`, `location.updated`, `location.blocked`, `location.unblocked`, `location.deleted` | | **Batch Events** | Batch processing and settlement | `batch.opened`, `batch.submitted`, `batch.accepted`, `batch.partially_accepted`, `batch.rejected`, `batch.edited` | | **[API Key](/docs/appendix/glossary#api-key "Secret header credential for REST calls; v5 keys use scoped permissions.") Events** | API key management events | `api_key.created`, `api_key.revoked`, `api_key.reinstated`, `api_key.deleted` | | **Credential Events** | Merchant credential management events | `credential.created`, `credential.blocked`, `credential.unblocked`, `credential.deleted` | For a complete list of available events with schemas and examples, see our [Available Events guide](/docs/webhooks/available-events). ### Webhook Headers Koard uses industry-standard webhook headers powered by Svix for maximum compatibility: ```plaintext Content-Type: application/json svix-id: msg_p5jXN8AQM9LWM0D4loKWxJek svix-timestamp: 1614265330 svix-signature: v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE= User-Agent: Svix-Webhooks/1.0 ``` **Header Descriptions:** * `svix-id`: Unique webhook delivery identifier used for idempotent handling of retried deliveries * `svix-timestamp`: Unix timestamp when the webhook was sent (for replay attack prevention) * `svix-signature`: HMAC signature for verifying webhook authenticity * `Content-Type`: Always `application/json` ### Retry Policy Koard implements a robust retry policy with exponential backoff for failed webhook deliveries: | Retry Attempt | Delay | Time from First Attempt | | ------------- | ---------- | ----------------------- | | 1st | Immediate | 0 seconds | | 2nd | 5 seconds | 5 seconds | | 3rd | 5 minutes | \~5 minutes | | 4th | 30 minutes | \~35 minutes | | 5th | 2 hours | \~2 hours 35 minutes | | 6th | 5 hours | \~7 hours 35 minutes | | 7th | 10 hours | \~17 hours 35 minutes | | 8th | 10 hours | \~27 hours 35 minutes | **Key Points:** * **Response Timeout**: 15 seconds per attempt * **Success Criteria**: 2xx status code (200-299) indicates success * **Failure Criteria**: Any other status code or timeout triggers a retry * **Endpoint Disabling**: After 5 days of consecutive failures, endpoints are automatically disabled * **Manual Recovery**: Use the dashboard to recover or resend failed messages **Note**: When responding to webhooks, return a 2xx status code quickly. Process complex workflows asynchronously to avoid timeouts. ## Testing Your Webhook ### Create a Test Endpoint To test your webhook submission, create a webhook that can accept all transaction events and use the Koard SDK to process some transactions. This example uses your application's durable queue adapter: `webhookQueue.enqueue()` must resolve only after persistence succeeds. Deduplicate using the Svix message ID, and have a separate worker call `processWebhook()` and retry failed jobs. The business handlers and queue adapter are application code. ```javascript // Express.js example webhook endpoint const express = require('express'); const { Webhook } = require('svix'); const { webhookQueue } = require('./webhook-queue'); const app = express(); // Use express.raw so req.body is the exact bytes Svix signed. Do NOT JSON-parse // and re-stringify the body — that can change the bytes and fail verification. app.post('/webhooks/koard', express.raw({ type: 'application/json' }), async (req, res) => { const payload = req.body; // raw Buffer, exactly as received const headers = { 'svix-id': req.headers['svix-id'], 'svix-timestamp': req.headers['svix-timestamp'], 'svix-signature': req.headers['svix-signature'] }; let verifiedPayload; try { // Verify webhook signature using Svix library const wh = new Webhook(process.env.KOARD_WEBHOOK_SECRET); verifiedPayload = wh.verify(payload, headers); } catch (err) { console.error('Webhook verification failed:', err); return res.status(400).send('Invalid signature'); } try { await webhookQueue.enqueue({ id: headers['svix-id'], payload: verifiedPayload }); return res.status(200).send('OK'); } catch (err) { console.error('Webhook persistence failed:', err); return res.status(503).send('Retry later'); } }); async function processWebhook(payload) { // The body IS the resource object (flat) — there is no { event, data } envelope, // and the event type is not in the body. Subscribe each endpoint to specific // event types (developer portal), and/or branch on payload fields. For a // transaction endpoint, route on transaction_type and read status. const txn = payload; switch (txn.transaction_type) { case 'sale': await handleSale(txn); break; case 'capture': await handleCapture(txn); break; // Add more: 'auth', 'refund', 'reverse', 'tip_adjust', 'incremental_auth' default: console.log('Unhandled transaction type:', txn.transaction_type); } } app.listen(3000, () => { console.log('Webhook endpoint listening on port 3000'); }); ``` ### Test with Koard SDK Use the Koard SDK to process transactions and trigger webhook events: ```swift import KoardSDK // After login, active location selection, linking, and reader preparation. // Persist this eventId with your order before submitting. let eventId = UUID().uuidString let response = try await KoardMerchantSDK.shared.sale( amount: 1000, breakdown: PaymentBreakdown(subtotal: 1000, taxAmount: 0, tipType: .fixed), currency: CurrencyCode(currencyCode: "USD", displayName: "US Dollar"), eventId: eventId ) print(response.transaction?.status as Any) ``` ![webhooks-4](/webhooks-4.png) _Testing webhook functionality with Koard SDK transactions_ ## Webhook Security ### Signature Verification **Why Verify Webhooks?** Webhook signatures let you verify that webhook messages are actually sent by Koard and not a malicious actor. This prevents: * **Spoofing Attacks**: Malicious actors sending fake webhooks * **Replay Attacks**: Old webhooks being resent * **Man-in-the-Middle Attacks**: Webhooks being intercepted and modified For a detailed explanation, see [why you should verify webhooks](https://docs.svix.com/receiving/verifying-payloads/why). ### Using Svix Libraries (Recommended) Koard uses [Svix](https://www.svix.com/) for webhook delivery, which provides official libraries for easy verification: ```javascript Node.js const { Webhook } = require("svix"); const secret = process.env.KOARD_WEBHOOK_SECRET; // Get from Koard dashboard // Use express.raw so req.body is the exact bytes Svix signed. Do NOT JSON-parse // and re-stringify the body — that can change the bytes and fail verification. app.post('/webhooks/koard', express.raw({ type: 'application/json' }), (req, res) => { const payload = req.body; // raw Buffer, exactly as received const headers = { 'svix-id': req.headers['svix-id'], 'svix-timestamp': req.headers['svix-timestamp'], 'svix-signature': req.headers['svix-signature'] }; try { const wh = new Webhook(secret); const verifiedPayload = wh.verify(payload, headers); // Webhook is verified - process it processWebhook(verifiedPayload); res.status(200).json({ success: true }); } catch (err) { console.error('Webhook verification failed:', err.message); res.status(400).json({ error: 'Invalid signature' }); } }); ``` ```python Python from svix.webhooks import Webhook import os webhook_secret = os.environ['KOARD_WEBHOOK_SECRET'] @app.route('/webhooks/koard', methods=['POST']) def webhook_handler(): payload = request.get_data() headers = { 'svix-id': request.headers.get('svix-id'), 'svix-timestamp': request.headers.get('svix-timestamp'), 'svix-signature': request.headers.get('svix-signature') } try: wh = Webhook(webhook_secret) msg = wh.verify(payload, headers) # Webhook is verified - process it process_webhook(msg) return jsonify({'success': True}), 200 except Exception as e: print(f'Webhook verification failed: {e}') return jsonify({'error': 'Invalid signature'}), 400 ``` ```go Go import ( "encoding/json" svix "github.com/svix/svix-webhooks/go" ) func webhookHandler(w http.ResponseWriter, r *http.Request) { webhookSecret := os.Getenv("KOARD_WEBHOOK_SECRET") payload, _ := ioutil.ReadAll(r.Body) headers := http.Header{} headers.Set("svix-id", r.Header.Get("svix-id")) headers.Set("svix-timestamp", r.Header.Get("svix-timestamp")) headers.Set("svix-signature", r.Header.Get("svix-signature")) wh, _ := svix.NewWebhook(webhookSecret) err := wh.Verify(payload, headers) if err != nil { w.WriteHeader(http.StatusBadRequest) return } // Verified — decode the flat body before routing on its fields var txn map[string]interface{} if err := json.Unmarshal(payload, &txn); err != nil { w.WriteHeader(http.StatusBadRequest) return } processWebhook(txn) w.WriteHeader(http.StatusOK) } ``` For more examples in other languages (Ruby, PHP, Java, Rust, Kotlin, C#), see the [Svix webhook verification documentation](https://docs.svix.com/receiving/verifying-payloads/how). ### Manual Verification (Advanced) If you prefer to verify signatures manually without using the Svix library: ```javascript const crypto = require('crypto'); function verifyWebhookSignature(payload, headers, secret) { const timestamp = headers['svix-timestamp']; const signature = headers['svix-signature']; const msgId = headers['svix-id']; // Check timestamp to prevent replay attacks (optional but recommended) const timestampSeconds = parseInt(timestamp); const now = Math.floor(Date.now() / 1000); if (Math.abs(now - timestampSeconds) > 300) { // 5 minute tolerance throw new Error('Webhook timestamp too old'); } // Create the signed content const signedContent = ${msgId}.${timestamp}.${payload}; // Compute expected signature const expectedSignature = crypto .createHmac('sha256', secret) .update(signedContent, 'utf8') .digest('base64'); // Extract signature from header (format: "v1,signature") const signatureParts = signature.split(','); const headerSignature = signatureParts[1]; // Compare signatures return crypto.timingSafeEqual( Buffer.from(headerSignature), Buffer.from(expectedSignature) ); } ``` **Important**: Use the **raw payload body** for verification, not the parsed JSON. Different JSON parsers may produce different string representations. ### HTTPS Requirements * **HTTPS Only**: Webhook endpoints must use HTTPS * **Valid SSL Certificates**: SSL certificates must be valid and trusted * **No Self-Signed Certificates**: Self-signed certificates are not allowed * **TLS 1.2+**: Minimum TLS version 1.2 required ### Security Best Practices * **Verify Signatures**: Always verify webhook signatures * **Use HTTPS**: Only accept webhooks over HTTPS * **Validate Payloads**: Validate webhook payloads before processing * **Rate Limiting**: Implement rate limiting to prevent abuse * **Logging**: Log all webhook events for debugging and security ## Monitoring and Logging ### Webhook Logs All webhooks come with detailed logging around deliverability, attempts, and activity history: 1. **Access Logs**: Go to the Logs tab in the developer portal 2. **Filter Events**: Filter by event type and message content 3. **View Details**: Click on individual events to see delivery details 4. **Monitor Status**: Track delivery status and retry attempts ![webhooks-3](/webhooks-3.png) _Webhook logs and delivery monitoring in the Koard Developer Portal_ ### Delivery Status Monitor webhook delivery status: | Status | Description | | ------------- | --------------------------------------------------- | | **Delivered** | Webhook successfully delivered and acknowledged | | **Pending** | Webhook delivery in progress or scheduled for retry | | **Failed** | Webhook delivery failed after all retry attempts | ### Troubleshooting Common webhook issues and solutions: **Not Using the Raw Payload Body** This is the most common issue. When generating the signed content, Koard uses the raw string body of the message payload. If you convert JSON payloads into strings using methods like `JSON.stringify()`, different implementations may produce different string representations, leading to verification failures. **Solution:** Use the raw request body exactly as received. In Express.js: use `express.raw()` or access `req.body` before JSON parsing. **Missing or Wrong Secret Key** Using an incorrect or outdated webhook secret will cause all verifications to fail. **Solution:** Get your webhook secret from the Koard Developer Portal. Remember that secrets are unique to each endpoint. **Timestamp Too Old** Webhooks with timestamps older than 5 minutes are rejected to prevent replay attacks. **Solution:** Ensure your server's system time is synchronized (use NTP). **Sending Wrong Response Codes** When Koard receives a 2xx status code (200-299), it's interpreted as successful delivery, even if your response payload indicates a failure. **Solution:** Return appropriate status codes: * `200` for successful processing * `400-499` for client errors (will not retry) * `500-599` for server errors (will retry) **Response Timeouts** Webhooks that don't respond within 15 seconds are considered failed and will be retried. **Solution:** Respond immediately with `200 OK` and process webhooks asynchronously: ```javascript app.post('/webhooks/koard', async (req, res) => { // Verify signature const wh = new Webhook(secret); const payload = wh.verify(req.body, req.headers); // Respond immediately res.status(200).send('OK'); // Process asynchronously queue.add('process-webhook', payload); }); ``` ### Failure Recovery **Re-enable a Disabled Endpoint** If all attempts to a specific endpoint fail for 5 days, the endpoint will be automatically disabled. **To re-enable:** 1. Go to the Koard Developer Portal 2. Navigate to Webhooks 3. Find the disabled endpoint 4. Click "Enable Endpoint" **Recovering Failed Messages** **Single Message Recovery:** 1. Find the message in the Developer Portal 2. Click the options menu next to the attempt 3. Click "Resend" to retry delivery **Bulk Message Recovery:** 1. Go to the endpoint details page 2. Click "Options" → "Recover Failed Messages" 3. Choose a time window to recover from 4. All failed messages in that window will be resent **Recovery from Specific Timestamp:** 1. Find any message near your desired recovery point 2. Click the options menu on that message 3. Select "Replay all failed messages since this time" ## Best Practices ### Endpoint Design * **Quick Response**: Return 2xx status codes quickly (within 15 seconds) * **Persist, Respond, Process**: Verify the signature, persist the event and its job durably, then acknowledge and process asynchronously * **Disable CSRF Protection**: Disable CSRF checks for webhook endpoints * **Use Raw Body**: Access raw request body for signature verification * **Error Handling**: Implement proper error handling and logging ### Event Processing - Idempotency **Why Idempotency Matters:** Webhooks may be delivered more than once due to network issues, retries, or recovery operations. Your endpoint must handle duplicate events gracefully. **Implementation:** Use the `svix-id` header as the unique delivery ID in the [durable queue example](#create-a-test-endpoint). That example returns `200` only after `webhookQueue.enqueue()` succeeds and `503` when persistence fails. The queue adapter must atomically persist the delivery ID, verified payload, and worker job. A unique constraint on the delivery ID prevents concurrent retries from creating duplicate jobs. An already-persisted delivery can be acknowledged because its job is still queued, being retried, or completed. Keep **delivery persistence** separate from **business completion**. Workers mark a job completed only after business processing succeeds, retain failed jobs for retry, and recover interrupted jobs after a restart. Do not record an event as processed before calling the business handler, or return success while an unpersisted handler runs in the background; either pattern can lose a failed event permanently. **Best Practices:** * Verify the signature over the raw request body before accepting the delivery. * Await durable persistence before returning `200`; return a non-2xx response if persistence fails. * Deduplicate atomically in durable storage, rather than with an in-memory `Set` or a separate read-then-insert. * Make business handlers idempotent too: worker retries can repeat a side effect after an uncertain outcome. * Retain delivery IDs for your replay window and keep pending or failed jobs until they are resolved. ### Event Ordering **Important:** Delivery is best-effort ordered — events may arrive out of sequence due to network conditions, retries, or processing delays. **Order by the payload's `created_at`.** Koard is event-sourced: a transaction's lifecycle (`authorize` → `incremental_auth` → `capture` → `refund` → …) is a series of events that **share one `transaction_id`**, each with its own `event_id` and its own `created_at`. That `created_at` is assigned as `max(now, previous_event + 1)` in **milliseconds**, so within a `transaction_id` it is **strictly increasing** — a reliable per-event ordering key. Don't use the `svix-timestamp` header for ordering — it's the delivery-_attempt_ time and changes on retries. **Persist atomically** so out-of-order and concurrent deliveries can't regress state: upsert and only advance when the incoming `created_at` is greater than the stored watermark (a single statement, no read-then-write race). Deduplicate exact retries on `svix-id` (see the Idempotency section above). ```javascript app.post('/webhooks/koard', express.raw({ type: 'application/json' }), async (req, res) => { const wh = new Webhook(secret); const txn = wh.verify(req.body, { 'svix-id': req.headers['svix-id'], 'svix-timestamp': req.headers['svix-timestamp'], 'svix-signature': req.headers['svix-signature'], }); // created_at is a per-event, strictly increasing (ms) watermark for a // transaction_id. Upsert atomically and only advance when this event is // newer, so out-of-order or concurrent deliveries can't regress the state. await db.query( INSERT INTO transactions (transaction_id, status, payload, last_created_at) VALUES ($1, $2, $3, $4) ON CONFLICT (transaction_id) DO UPDATE SET status = EXCLUDED.status, payload = EXCLUDED.payload, last_created_at = EXCLUDED.last_created_at WHERE EXCLUDED.last_created_at > transactions.last_created_at, [txn.transaction_id, txn.status, txn, txn.created_at], ); res.status(200).send('OK'); }); ``` If you need strict, guaranteed ordering, use [Svix FIFO endpoints](https://docs.svix.com/advanced-endpoints/fifo-endpoints). Alternatively, treat the webhook as a signal and re-fetch the authoritative transaction from the Koard API before acting. ### Security * **Always Verify Signatures**: Never skip signature verification in production * **Use Svix Libraries**: Use official Svix libraries for proper verification * **HTTPS Only**: Use HTTPS for all webhook endpoints (required by Koard) * **Validate Timestamps**: Reject webhooks with old timestamps (>5 minutes) * **Secret Management**: Securely store webhook secrets (use environment variables) * **Rotate Secrets**: Periodically rotate webhook secrets * **Monitor Failures**: Alert on repeated verification failures (potential attack) ### Monitoring and Alerting * **Track Delivery**: Monitor webhook delivery success rates * **Set Up Alerts**: Alert on repeated failures or timeouts * **Log Everything**: Log all webhook events for debugging * **Monitor Processing Time**: Ensure webhooks process within timeout * **Dashboard Review**: Regularly review webhook logs in Koard Developer Portal ## See also This wraps up the webhook setup guide. See the links below for related information: * [Available Events](/docs/webhooks/available-events) - Complete list of webhook events * [Running Payments](/docs/setting-up-the-ios-sdk/running-payments) - Payment processing fundamentals * [Batch and Settlements](/docs/batch-and-settlements/overview) - Batch processing overview * [Getting Started](/docs/introduction) - Koard platform introduction # Available Events Complete list of webhook events Koard can deliver, with the **exact payload your endpoint receives**. ## Payload shape Almost every webhook request body is the **resource object itself, as flat JSON** — for example a `batch.accepted` body is a Batch, a `transaction.sale` body is a Transaction. There is **no `{"event": ..., "data": ...}` envelope**; the fields shown below are top-level. The exception is the **`*.deleted` events** (`account.deleted`, `terminal.deleted`, `location.deleted`), which carry a sparse **id-only** payload — just the resource id (and `account_id` where applicable), not the full object, since the resource no longer exists. Each event's exact body is shown below. **The event type is not in the request body.** Koard delivers webhooks through Svix, so the body is the raw payload and the delivery metadata rides in headers (`svix-id`, `svix-timestamp`, `svix-signature`). To know which event an endpoint received, **subscribe that endpoint to specific event types** in the developer portal (one endpoint per event or per category). Transactions and batches also carry discriminators in the body — branch on `transaction_type` / `status` for transactions and on `status` for batches. Conventions used in every payload: * **Money is in minor units** (cents) — `5000` means $50.00. * **Timestamps:** transaction `created_at` is a Unix timestamp in **milliseconds**; every other resource (`batch`, `account`, `terminal`, `location`, `api_key`, `credential`) uses **ISO-8601** strings (`opened_at`, `created_at`, …). * **Secrets are masked:** the card is truncated (`4113********4242`), an API key is `"****" + last4`, and a credential PIN is `"****"`. Full secrets and hash material are never sent. * Unset optional fields are present with a `null` value. ## Surcharge cancellation For Fiserv Nashville Global, rejecting a pending surcharge with `confirm(..., false)` produces `status: canceled` and `status_reason: surcharge_declined_by_payer`. Attempting to accept after the 120-second context expires produces `status_reason: surcharge_expired`. This flow has not authorized the pending sale, so these outcomes are cancellations, not reversals of an existing hold. Both confirmation cancellation and background expiry cleanup emit **`transaction.cancel`** with `status: canceled`. Inspect `status_reason` to distinguish rejection from expiry, and use `event_id` to deduplicate deliveries. For refunds, `tax_amount` and `surcharge_amount` on a refund event describe that refund's components. See [refund breakdowns](/docs/payments/methods/refund#fiserv-nashville-global-refund-breakdown). ## Transaction outcomes Payment attempt webhooks are named after the **operation attempted**: a sale emits `transaction.sale` and an authorization emits `transaction.authorize`, whether approved, declined or errored. Pending payments emit `transaction.create`; cancellations emit `transaction.cancel`. The **outcome** lives in the payload: * **`status`** — the resulting state (e.g. `authorized`, `captured`, `declined`, `error`). * **`status_reason`** — a machine-readable reason for that status (e.g. `approved`, `insufficient_funds`, `payment_failed`). So your handler should **branch on `status`** to tell success from failure. **Failed and declined attempts fire the same event as a success.** A declined or errored **auth** is still delivered as `transaction.authorize`; a failed **sale** as `transaction.sale`. There is no separate `transaction.failed` or `transaction.declined` event — read `status` / `status_reason` to handle the result. ### Transaction statuses `status` reflects the resulting state of the transaction: | `status` | Meaning | Outcome | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------- | | `authorized` | Funds held, not yet captured | ✅ Success | | `captured` | Funds captured (sale or capture) | ✅ Success | | `settled` | Funds finalized in a settled batch | ✅ Success | | `refunded` | Refund completed | ✅ Success | | `reversed` | Authorization reversed / voided | ✅ Success | | `pending` | Awaiting external input | ⏳ In progress | | `surcharge_pending` | Awaiting [surcharge](/docs/appendix/glossary#surcharge "Fee on eligible credit-card transactions to offset processing costs (not debit).") confirmation | ⏳ In progress | | `declined` | Declined by the issuer | ❌ Failure | | `error` | The attempt could not be completed (host / format / network error) | ❌ Failure | | `canceled` | Canceled by the user or system | ⛔ Not completed | ### Status reasons `status_reason` explains **why** a transaction ended in its `status`. Common values: | `status_reason` | Meaning | | --------------------------------------------------- | ------------------------------------------------------------------------- | | `approved` | The operation was approved | | `declined` | The issuer declined the transaction | | `insufficient_funds` | Declined — the account had insufficient funds | | `invalid_card` | The card was invalid or expired | | `payment_failed` | The host / gateway returned an unclassified failure (not a clean decline) | | `processor_timeout` / `timed_out` | No response from the processor in time | | `network_connectivity_error` | Could not reach the processor | | `invalid_gateway_response` / `format_error` | The processor response could not be parsed | | `invalid_terminal` | The terminal / BIN is not boarded or is misconfigured | | `partial_approval` | Approved for less than the requested amount | | `incremental_auth_declined` | An incremental authorization was declined | | `invalid_tip_adjustment` | A tip adjustment is not allowed for this transaction | | `exceeds_original_auth` | A capture / adjustment exceeds the original authorization | | `invalid_capture_sequence` | A capture was attempted in an invalid state | | `surcharge_declined_by_payer` / `surcharge_expired` | The cardholder declined the surcharge, or the prompt expired | | `user_canceled` / `merchant_canceled` | Canceled by the cardholder or the merchant | | `duplicate_event` | A duplicate event ID was received | ### Parsing a transaction webhook Verify the Svix signature first (see [Setting up Webhooks](/docs/webhooks/setting-up-webhooks)), then read the **flat** transaction body. This endpoint is subscribed to transaction events, so the body is a Transaction; route on `transaction_type` and branch on `status`. ```python Python # POST https://your-app.com/webhooks/koard (subscribed to transaction.* events) from flask import request, jsonify SUCCESS_STATUSES = {"authorized", "captured", "settled", "refunded", "reversed"} IN_PROGRESS_STATUSES = {"pending", "surcharge_pending"} @app.route("/webhooks/koard", methods=["POST"]) def koard_webhook(): txn = request.get_json() # the body IS the transaction (flat, no envelope) operation = txn["transaction_type"] # sale / auth / capture / refund / reverse / ... status = txn.get("status") if status in SUCCESS_STATUSES: handle_success(operation, txn) elif status in IN_PROGRESS_STATUSES: handle_in_progress(operation, status, txn) # awaiting input; not final yet elif status == "canceled": handle_canceled(operation, txn) # not completed else: # only declined / error land here — status_reason explains why handle_failure(operation, status, txn.get("status_reason"), txn) return jsonify(received=True), 200 ``` ```javascript Node.js // POST https://your-app.com/webhooks/koard (subscribed to transaction.* events) const SUCCESS_STATUSES = new Set([ "authorized", "captured", "settled", "refunded", "reversed", ]); const IN_PROGRESS_STATUSES = new Set(["pending", "surcharge_pending"]); app.post("/webhooks/koard", (req, res) => { const txn = req.body; // the body IS the transaction (flat, no envelope) const { transaction_type: operation, status, status_reason } = txn; if (SUCCESS_STATUSES.has(status)) { handleSuccess(operation, txn); } else if (IN_PROGRESS_STATUSES.has(status)) { handleInProgress(operation, status, txn); // awaiting input; not final yet } else if (status === "canceled") { handleCanceled(operation, txn); // not completed } else { // only declined / error land here — `status_reason` explains why handleFailure(operation, status, status_reason, txn); } res.status(200).json({ received: true }); }); ``` ```ruby Ruby # POST https://your-app.com/webhooks/koard (subscribed to transaction.* events) SUCCESS_STATUSES = %w[authorized captured settled refunded reversed].freeze IN_PROGRESS_STATUSES = %w[pending surcharge_pending].freeze post "/webhooks/koard" do txn = JSON.parse(request.body.read) # the body IS the transaction (flat) operation = txn["transaction_type"] status = txn["status"] if SUCCESS_STATUSES.include?(status) handle_success(operation, txn) elsif IN_PROGRESS_STATUSES.include?(status) handle_in_progress(operation, status, txn) # awaiting input; not final yet elsif status == "canceled" handle_canceled(operation, txn) # not completed else # only declined / error land here handle_failure(operation, status, txn["status_reason"], txn) end status 200 { received: true }.to_json end ``` ```java Java // POST https://your-app.com/webhooks/koard (subscribed to transaction.* events) private static final Set SUCCESS_STATUSES = Set.of("authorized", "captured", "settled", "refunded", "reversed"); private static final Set IN_PROGRESS_STATUSES = Set.of("pending", "surcharge_pending"); @PostMapping("/webhooks/koard") public ResponseEntity koardWebhook(@RequestBody Map txn) { String operation = (String) txn.get("transaction_type"); String status = (String) txn.get("status"); if (SUCCESS_STATUSES.contains(status)) { handleSuccess(operation, txn); } else if (IN_PROGRESS_STATUSES.contains(status)) { handleInProgress(operation, status, txn); // awaiting input; not final yet } else if ("canceled".equals(status)) { handleCanceled(operation, txn); // not completed } else { // only declined / error land here handleFailure(operation, status, (String) txn.get("status_reason"), txn); } return ResponseEntity.ok().build(); } ``` ```php PHP true]); ``` The examples below show the exact body delivered for each event. ## Transaction Events ### transaction.authorize Card authorized — funds held, not yet captured. ```json { "transaction_id": "6f6b6d2e-1c3a-4a9e-9d21-2f0e7b8c9a10", "event_id": "6F6B6D2E-1C3A-4A9E-9D21-2F0E7B8C9A10", "mid": "445566778899", "tid": "TERM0001", "processor_mid": "5289003", "processor_tid": "5289003", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv", "device_id": "dev_5Hj3Kl8Mn2Pq6Rt", "processor": "tsys", "gateway": "sierra", "currency": "USD", "location_id": "loc_7Bd4Cf9Gh2Jk5Lm", "gateway_transaction_id": "BG6RJZHK8Q", "subtotal": 4200, "tip_amount": 800, "tip_type": "amount", "tip_capture_mode": null, "tax_amount": 0, "tax_rate": 0, "tax_source": null, "tax_basis": null, "total_amount": 5000, "surcharge_applied": false, "surcharge_amount": 0, "surcharge_rate": 0, "surcharge_source": null, "surcharge_basis": null, "surcharge_confirmation_required": null, "refunded": 0, "reversed": 0, "created_at": 1768470600000, "status": "authorized", "status_reason": "approved", "payment_method": "contactlessIcc", "card_type": "credit", "card_brand": "visa", "card": "4113********4242", "additional_details": {}, "gateway_transaction_response": { "responseCode": "A", "responseMessage": "APPROVAL", "approvalCode": "522841", "processorResponseCode": "00" }, "processor_response_code": "00", "processor_response_message": "Approved", "transaction_type": "auth", "apple_transaction_id": "", "reader_identifier": "rdr_8Wq2Er5Ty7Ui", "owner_id": "acct_2Nk9Lm4Pq7Rs1Tv", "parent_account_ids": [ "acct_0Pp1Qq2Rr3Ss4Tt" ], "merchant_name": "Blue Bottle Coffee", "psp": null, "batch_id": "batch_6Yh3Uj8Ik2Ol", "device_type": null, "terminal_details": null, "history": [] } ``` ### transaction.sale Sale — card authorized and captured in one step. ```json { "transaction_id": "6f6b6d2e-1c3a-4a9e-9d21-2f0e7b8c9a10", "event_id": "6F6B6D2E-1C3A-4A9E-9D21-2F0E7B8C9A10", "mid": "445566778899", "tid": "TERM0001", "processor_mid": "5289003", "processor_tid": "5289003", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv", "device_id": "dev_5Hj3Kl8Mn2Pq6Rt", "processor": "tsys", "gateway": "sierra", "currency": "USD", "location_id": "loc_7Bd4Cf9Gh2Jk5Lm", "gateway_transaction_id": "BG6RJZHK8Q", "subtotal": 4200, "tip_amount": 800, "tip_type": "amount", "tip_capture_mode": null, "tax_amount": 0, "tax_rate": 0, "tax_source": null, "tax_basis": null, "total_amount": 5000, "surcharge_applied": false, "surcharge_amount": 0, "surcharge_rate": 0, "surcharge_source": null, "surcharge_basis": null, "surcharge_confirmation_required": null, "refunded": 0, "reversed": 0, "created_at": 1768470600000, "status": "captured", "status_reason": "approved", "payment_method": "contactlessIcc", "card_type": "credit", "card_brand": "visa", "card": "4113********4242", "additional_details": {}, "gateway_transaction_response": { "responseCode": "A", "responseMessage": "APPROVAL", "approvalCode": "522841", "processorResponseCode": "00" }, "processor_response_code": "00", "processor_response_message": "Approved", "transaction_type": "sale", "apple_transaction_id": "", "reader_identifier": "rdr_8Wq2Er5Ty7Ui", "owner_id": "acct_2Nk9Lm4Pq7Rs1Tv", "parent_account_ids": [ "acct_0Pp1Qq2Rr3Ss4Tt" ], "merchant_name": "Blue Bottle Coffee", "psp": null, "batch_id": "batch_6Yh3Uj8Ik2Ol", "device_type": null, "terminal_details": null, "history": [] } ``` ### transaction.capture A prior authorization was captured for settlement. ```json { "transaction_id": "6f6b6d2e-1c3a-4a9e-9d21-2f0e7b8c9a10", "event_id": "6F6B6D2E-1C3A-4A9E-9D21-2F0E7B8C9A10", "mid": "445566778899", "tid": "TERM0001", "processor_mid": "5289003", "processor_tid": "5289003", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv", "device_id": "dev_5Hj3Kl8Mn2Pq6Rt", "processor": "tsys", "gateway": "sierra", "currency": "USD", "location_id": "loc_7Bd4Cf9Gh2Jk5Lm", "gateway_transaction_id": "BG6RJZHK8Q", "subtotal": 4200, "tip_amount": 800, "tip_type": "amount", "tip_capture_mode": null, "tax_amount": 0, "tax_rate": 0, "tax_source": null, "tax_basis": null, "total_amount": 5000, "surcharge_applied": false, "surcharge_amount": 0, "surcharge_rate": 0, "surcharge_source": null, "surcharge_basis": null, "surcharge_confirmation_required": null, "refunded": 0, "reversed": 0, "created_at": 1768470600000, "status": "captured", "status_reason": "approved", "payment_method": "contactlessIcc", "card_type": "credit", "card_brand": "visa", "card": "4113********4242", "additional_details": {}, "gateway_transaction_response": { "responseCode": "A", "responseMessage": "APPROVAL", "approvalCode": "522841", "processorResponseCode": "00" }, "processor_response_code": "00", "processor_response_message": "Approved", "transaction_type": "capture", "apple_transaction_id": "", "reader_identifier": "rdr_8Wq2Er5Ty7Ui", "owner_id": "acct_2Nk9Lm4Pq7Rs1Tv", "parent_account_ids": [ "acct_0Pp1Qq2Rr3Ss4Tt" ], "merchant_name": "Blue Bottle Coffee", "psp": null, "batch_id": "batch_6Yh3Uj8Ik2Ol", "device_type": null, "terminal_details": null, "history": [] } ``` ### transaction.cancel Transaction cancelled before settlement. ```json { "transaction_id": "6f6b6d2e-1c3a-4a9e-9d21-2f0e7b8c9a10", "event_id": "6F6B6D2E-1C3A-4A9E-9D21-2F0E7B8C9A10", "mid": "445566778899", "tid": "TERM0001", "processor_mid": "5289003", "processor_tid": "5289003", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv", "device_id": "dev_5Hj3Kl8Mn2Pq6Rt", "processor": "tsys", "gateway": "sierra", "currency": "USD", "location_id": "loc_7Bd4Cf9Gh2Jk5Lm", "gateway_transaction_id": "BG6RJZHK8Q", "subtotal": 4200, "tip_amount": 800, "tip_type": "amount", "tip_capture_mode": null, "tax_amount": 0, "tax_rate": 0, "tax_source": null, "tax_basis": null, "total_amount": 5000, "surcharge_applied": false, "surcharge_amount": 0, "surcharge_rate": 0, "surcharge_source": null, "surcharge_basis": null, "surcharge_confirmation_required": null, "refunded": 0, "reversed": 0, "created_at": 1768470600000, "status": "canceled", "status_reason": "user_canceled", "payment_method": "contactlessIcc", "card_type": "credit", "card_brand": "visa", "card": "4113********4242", "additional_details": {}, "gateway_transaction_response": { "responseCode": "A", "responseMessage": "APPROVAL", "approvalCode": "522841", "processorResponseCode": "00" }, "processor_response_code": "00", "processor_response_message": "Approved", "transaction_type": "sale", "apple_transaction_id": "", "reader_identifier": "rdr_8Wq2Er5Ty7Ui", "owner_id": "acct_2Nk9Lm4Pq7Rs1Tv", "parent_account_ids": [ "acct_0Pp1Qq2Rr3Ss4Tt" ], "merchant_name": "Blue Bottle Coffee", "psp": null, "batch_id": "batch_6Yh3Uj8Ik2Ol", "device_type": null, "terminal_details": null, "history": [] } ``` ### transaction.create A transaction record was created. ```json { "transaction_id": "6f6b6d2e-1c3a-4a9e-9d21-2f0e7b8c9a10", "event_id": "6F6B6D2E-1C3A-4A9E-9D21-2F0E7B8C9A10", "mid": "445566778899", "tid": "TERM0001", "processor_mid": "5289003", "processor_tid": "5289003", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv", "device_id": "dev_5Hj3Kl8Mn2Pq6Rt", "processor": "tsys", "gateway": "sierra", "currency": "USD", "location_id": "loc_7Bd4Cf9Gh2Jk5Lm", "gateway_transaction_id": "BG6RJZHK8Q", "subtotal": 4200, "tip_amount": 800, "tip_type": "amount", "tip_capture_mode": null, "tax_amount": 0, "tax_rate": 0, "tax_source": null, "tax_basis": null, "total_amount": 5000, "surcharge_applied": false, "surcharge_amount": 0, "surcharge_rate": 0, "surcharge_source": null, "surcharge_basis": null, "surcharge_confirmation_required": null, "refunded": 0, "reversed": 0, "created_at": 1768470600000, "status": "pending", "status_reason": "pending", "payment_method": "contactlessIcc", "card_type": "credit", "card_brand": "visa", "card": "4113********4242", "additional_details": {}, "gateway_transaction_response": { "responseCode": "A", "responseMessage": "APPROVAL", "approvalCode": "522841", "processorResponseCode": "00" }, "processor_response_code": "00", "processor_response_message": "Approved", "transaction_type": "sale", "apple_transaction_id": "", "reader_identifier": "rdr_8Wq2Er5Ty7Ui", "owner_id": "acct_2Nk9Lm4Pq7Rs1Tv", "parent_account_ids": [ "acct_0Pp1Qq2Rr3Ss4Tt" ], "merchant_name": "Blue Bottle Coffee", "psp": null, "batch_id": null, "device_type": null, "terminal_details": null, "history": [] } ``` ### transaction.reverse Authorization voided/undone after auth, before settlement. ```json { "transaction_id": "6f6b6d2e-1c3a-4a9e-9d21-2f0e7b8c9a10", "event_id": "6F6B6D2E-1C3A-4A9E-9D21-2F0E7B8C9A10", "mid": "445566778899", "tid": "TERM0001", "processor_mid": "5289003", "processor_tid": "5289003", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv", "device_id": "dev_5Hj3Kl8Mn2Pq6Rt", "processor": "tsys", "gateway": "sierra", "currency": "USD", "location_id": "loc_7Bd4Cf9Gh2Jk5Lm", "gateway_transaction_id": "BG6RJZHK8Q", "subtotal": 4200, "tip_amount": 800, "tip_type": "amount", "tip_capture_mode": null, "tax_amount": 0, "tax_rate": 0, "tax_source": null, "tax_basis": null, "total_amount": 5000, "surcharge_applied": false, "surcharge_amount": 0, "surcharge_rate": 0, "surcharge_source": null, "surcharge_basis": null, "surcharge_confirmation_required": null, "refunded": 0, "reversed": 5000, "created_at": 1768470600000, "status": "reversed", "status_reason": "approved", "payment_method": "contactlessIcc", "card_type": "credit", "card_brand": "visa", "card": "4113********4242", "additional_details": {}, "gateway_transaction_response": { "responseCode": "A", "responseMessage": "APPROVAL", "approvalCode": "522841", "processorResponseCode": "00" }, "processor_response_code": "00", "processor_response_message": "Approved", "transaction_type": "reverse", "apple_transaction_id": "", "reader_identifier": "rdr_8Wq2Er5Ty7Ui", "owner_id": "acct_2Nk9Lm4Pq7Rs1Tv", "parent_account_ids": [ "acct_0Pp1Qq2Rr3Ss4Tt" ], "merchant_name": "Blue Bottle Coffee", "psp": null, "batch_id": "batch_6Yh3Uj8Ik2Ol", "device_type": null, "terminal_details": null, "history": [] } ``` ### transaction.refund Funds refunded to the cardholder after settlement. ```json { "transaction_id": "6f6b6d2e-1c3a-4a9e-9d21-2f0e7b8c9a10", "event_id": "6F6B6D2E-1C3A-4A9E-9D21-2F0E7B8C9A10", "mid": "445566778899", "tid": "TERM0001", "processor_mid": "5289003", "processor_tid": "5289003", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv", "device_id": "dev_5Hj3Kl8Mn2Pq6Rt", "processor": "tsys", "gateway": "sierra", "currency": "USD", "location_id": "loc_7Bd4Cf9Gh2Jk5Lm", "gateway_transaction_id": "BG6RJZHK8Q", "subtotal": 4200, "tip_amount": 800, "tip_type": "amount", "tip_capture_mode": null, "tax_amount": 0, "tax_rate": 0, "tax_source": null, "tax_basis": null, "total_amount": 5000, "surcharge_applied": false, "surcharge_amount": 0, "surcharge_rate": 0, "surcharge_source": null, "surcharge_basis": null, "surcharge_confirmation_required": null, "refunded": 5000, "reversed": 0, "created_at": 1768470600000, "status": "refunded", "status_reason": "approved", "payment_method": "contactlessIcc", "card_type": "credit", "card_brand": "visa", "card": "4113********4242", "additional_details": {}, "gateway_transaction_response": { "responseCode": "A", "responseMessage": "APPROVAL", "approvalCode": "522841", "processorResponseCode": "00" }, "processor_response_code": "00", "processor_response_message": "Approved", "transaction_type": "refund", "apple_transaction_id": "", "reader_identifier": "rdr_8Wq2Er5Ty7Ui", "owner_id": "acct_2Nk9Lm4Pq7Rs1Tv", "parent_account_ids": [ "acct_0Pp1Qq2Rr3Ss4Tt" ], "merchant_name": "Blue Bottle Coffee", "psp": null, "batch_id": "batch_6Yh3Uj8Ik2Ol", "device_type": null, "terminal_details": null, "history": [] } ``` ### transaction.increment An existing authorization amount was increased ([incremental auth](/docs/appendix/glossary#incremental-auth "Increase an existing authorization hold by a delta amount.")). ```json { "transaction_id": "6f6b6d2e-1c3a-4a9e-9d21-2f0e7b8c9a10", "event_id": "6F6B6D2E-1C3A-4A9E-9D21-2F0E7B8C9A10", "mid": "445566778899", "tid": "TERM0001", "processor_mid": "5289003", "processor_tid": "5289003", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv", "device_id": "dev_5Hj3Kl8Mn2Pq6Rt", "processor": "tsys", "gateway": "sierra", "currency": "USD", "location_id": "loc_7Bd4Cf9Gh2Jk5Lm", "gateway_transaction_id": "BG6RJZHK8Q", "subtotal": 6200, "tip_amount": 800, "tip_type": "amount", "tip_capture_mode": null, "tax_amount": 0, "tax_rate": 0, "tax_source": null, "tax_basis": null, "total_amount": 7000, "surcharge_applied": false, "surcharge_amount": 0, "surcharge_rate": 0, "surcharge_source": null, "surcharge_basis": null, "surcharge_confirmation_required": null, "refunded": 0, "reversed": 0, "created_at": 1768470600000, "status": "authorized", "status_reason": "approved", "payment_method": "contactlessIcc", "card_type": "credit", "card_brand": "visa", "card": "4113********4242", "additional_details": {}, "gateway_transaction_response": { "responseCode": "A", "responseMessage": "APPROVAL", "approvalCode": "522841", "processorResponseCode": "00" }, "processor_response_code": "00", "processor_response_message": "Approved", "transaction_type": "incremental_auth", "apple_transaction_id": "", "reader_identifier": "rdr_8Wq2Er5Ty7Ui", "owner_id": "acct_2Nk9Lm4Pq7Rs1Tv", "parent_account_ids": [ "acct_0Pp1Qq2Rr3Ss4Tt" ], "merchant_name": "Blue Bottle Coffee", "psp": null, "batch_id": "batch_6Yh3Uj8Ik2Ol", "device_type": null, "terminal_details": null, "history": [] } ``` ### transaction.tip\_adjust A tip was added or changed on a captured transaction. ```json { "transaction_id": "6f6b6d2e-1c3a-4a9e-9d21-2f0e7b8c9a10", "event_id": "6F6B6D2E-1C3A-4A9E-9D21-2F0E7B8C9A10", "mid": "445566778899", "tid": "TERM0001", "processor_mid": "5289003", "processor_tid": "5289003", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv", "device_id": "dev_5Hj3Kl8Mn2Pq6Rt", "processor": "tsys", "gateway": "sierra", "currency": "USD", "location_id": "loc_7Bd4Cf9Gh2Jk5Lm", "gateway_transaction_id": "BG6RJZHK8Q", "subtotal": 4200, "tip_amount": 1200, "tip_type": "amount", "tip_capture_mode": null, "tax_amount": 0, "tax_rate": 0, "tax_source": null, "tax_basis": null, "total_amount": 5400, "surcharge_applied": false, "surcharge_amount": 0, "surcharge_rate": 0, "surcharge_source": null, "surcharge_basis": null, "surcharge_confirmation_required": null, "refunded": 0, "reversed": 0, "created_at": 1768470600000, "status": "captured", "status_reason": "approved", "payment_method": "contactlessIcc", "card_type": "credit", "card_brand": "visa", "card": "4113********4242", "additional_details": {}, "gateway_transaction_response": { "responseCode": "A", "responseMessage": "APPROVAL", "approvalCode": "522841", "processorResponseCode": "00" }, "processor_response_code": "00", "processor_response_message": "Approved", "transaction_type": "tip_adjust", "apple_transaction_id": "", "reader_identifier": "rdr_8Wq2Er5Ty7Ui", "owner_id": "acct_2Nk9Lm4Pq7Rs1Tv", "parent_account_ids": [ "acct_0Pp1Qq2Rr3Ss4Tt" ], "merchant_name": "Blue Bottle Coffee", "psp": null, "batch_id": "batch_6Yh3Uj8Ik2Ol", "device_type": null, "terminal_details": null, "history": [] } ``` ## Batch Events ### batch.opened A settlement batch was opened. ```json { "id": "batch_6Yh3Uj8Ik2Ol", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv", "terminal_id": "term_5Hj3Kl8Mn2Pq6Rt", "processor_name": "tsys", "processor_config_id": "pcfg_4Df7Gh1Jk8Lm", "status": "open", "captured_amount": 0, "refunded_amount": 0, "transaction_count": 0, "processor_batch_id": null, "opened_at": "2026-01-15T08:00:00Z", "closed_at": null, "response": null, "batch_metadata": {} } ``` ### batch.rejected The processor rejected the batch. ```json { "id": "batch_6Yh3Uj8Ik2Ol", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv", "terminal_id": "term_5Hj3Kl8Mn2Pq6Rt", "processor_name": "tsys", "processor_config_id": "pcfg_4Df7Gh1Jk8Lm", "status": "rejected", "captured_amount": 128400, "refunded_amount": 5000, "transaction_count": 37, "processor_batch_id": "TSYS-BATCH-000481", "opened_at": "2026-01-15T08:00:00Z", "closed_at": null, "response": { "rejection_reason": "Host unavailable \u2014 retry" }, "batch_metadata": {} } ``` ### batch.accepted The processor accepted the batch for settlement. ```json { "id": "batch_6Yh3Uj8Ik2Ol", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv", "terminal_id": "term_5Hj3Kl8Mn2Pq6Rt", "processor_name": "tsys", "processor_config_id": "pcfg_4Df7Gh1Jk8Lm", "status": "accepted", "captured_amount": 128400, "refunded_amount": 5000, "transaction_count": 37, "processor_batch_id": "TSYS-BATCH-000481", "opened_at": "2026-01-15T08:00:00Z", "closed_at": "2026-01-15T20:00:00Z", "response": null, "batch_metadata": {} } ``` ### batch.partially\_accepted The processor accepted the batch with some rejected records. ```json { "id": "batch_6Yh3Uj8Ik2Ol", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv", "terminal_id": "term_5Hj3Kl8Mn2Pq6Rt", "processor_name": "tsys", "processor_config_id": "pcfg_4Df7Gh1Jk8Lm", "status": "partially_accepted", "captured_amount": 128400, "refunded_amount": 5000, "transaction_count": 37, "processor_batch_id": "TSYS-BATCH-000481", "opened_at": "2026-01-15T08:00:00Z", "closed_at": "2026-01-15T20:00:00Z", "response": { "accepted": 35, "rejected": 2 }, "batch_metadata": {} } ``` ### batch.edited A batch's transactions were edited before close. ```json { "id": "batch_6Yh3Uj8Ik2Ol", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv", "terminal_id": "term_5Hj3Kl8Mn2Pq6Rt", "processor_name": "tsys", "processor_config_id": "pcfg_4Df7Gh1Jk8Lm", "status": "open", "captured_amount": 128400, "refunded_amount": 5000, "transaction_count": 36, "processor_batch_id": "TSYS-BATCH-000481", "opened_at": "2026-01-15T08:00:00Z", "closed_at": null, "response": null, "batch_metadata": {} } ``` ### batch.submitted A batch was submitted to the processor for settlement. ```json { "id": "batch_6Yh3Uj8Ik2Ol", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv", "terminal_id": "term_5Hj3Kl8Mn2Pq6Rt", "processor_name": "tsys", "processor_config_id": "pcfg_4Df7Gh1Jk8Lm", "status": "submitted", "captured_amount": 128400, "refunded_amount": 5000, "transaction_count": 37, "processor_batch_id": "TSYS-BATCH-000481", "opened_at": "2026-01-15T08:00:00Z", "closed_at": null, "response": null, "batch_metadata": {} } ``` ## Account Events ### account.created A new account was created. ```json { "id": "acct_2Nk9Lm4Pq7Rs1Tv", "available_processor_configs": [], "tax_id": null, "mcc": "5812", "type": "merchant", "status": "active", "name": "Blue Bottle Coffee", "description": "Specialty coffee roaster", "parent_account_ids": [ "acct_0Pp1Qq2Rr3Ss4Tt" ], "address": { "street_line1": "1500 Market St", "street_line2": "Suite 200", "city": "San Francisco", "state": "CA", "zip": "94103", "country": "US" }, "surcharge_rate": null, "surcharge_confirmation_required": null, "surcharge_basis": null, "tax_rate": null, "tax_basis": null, "created_at": "2026-01-15T09:30:00Z", "deleted_at": null, "updated_at": null } ``` ### account.updated An account's details were updated. ```json { "id": "acct_2Nk9Lm4Pq7Rs1Tv", "available_processor_configs": [], "tax_id": null, "mcc": "5812", "type": "merchant", "status": "active", "name": "Blue Bottle Coffee Co.", "description": "Specialty coffee roaster", "parent_account_ids": [ "acct_0Pp1Qq2Rr3Ss4Tt" ], "address": { "street_line1": "1500 Market St", "street_line2": "Suite 200", "city": "San Francisco", "state": "CA", "zip": "94103", "country": "US" }, "surcharge_rate": null, "surcharge_confirmation_required": null, "surcharge_basis": null, "tax_rate": null, "tax_basis": null, "created_at": "2026-01-15T09:30:00Z", "deleted_at": null, "updated_at": null } ``` ### account.blocked An account was blocked (deactivated). ```json { "id": "acct_2Nk9Lm4Pq7Rs1Tv", "available_processor_configs": [], "tax_id": null, "mcc": "5812", "type": "merchant", "status": "blocked", "name": "Blue Bottle Coffee", "description": "Specialty coffee roaster", "parent_account_ids": [ "acct_0Pp1Qq2Rr3Ss4Tt" ], "address": { "street_line1": "1500 Market St", "street_line2": "Suite 200", "city": "San Francisco", "state": "CA", "zip": "94103", "country": "US" }, "surcharge_rate": null, "surcharge_confirmation_required": null, "surcharge_basis": null, "tax_rate": null, "tax_basis": null, "created_at": "2026-01-15T09:30:00Z", "deleted_at": null, "updated_at": null } ``` ### account.unblocked A blocked account was reactivated. ```json { "id": "acct_2Nk9Lm4Pq7Rs1Tv", "available_processor_configs": [], "tax_id": null, "mcc": "5812", "type": "merchant", "status": "active", "name": "Blue Bottle Coffee", "description": "Specialty coffee roaster", "parent_account_ids": [ "acct_0Pp1Qq2Rr3Ss4Tt" ], "address": { "street_line1": "1500 Market St", "street_line2": "Suite 200", "city": "San Francisco", "state": "CA", "zip": "94103", "country": "US" }, "surcharge_rate": null, "surcharge_confirmation_required": null, "surcharge_basis": null, "tax_rate": null, "tax_basis": null, "created_at": "2026-01-15T09:30:00Z", "deleted_at": null, "updated_at": null } ``` ### account.deleted An account was deleted (soft-deleted). ```json { "id": "acct_2Nk9Lm4Pq7Rs1Tv" } ``` ## Terminal Events ### terminal.created A new terminal was created. ```json { "terminal_id": "term_5Hj3Kl8Mn2Pq6Rt", "name": "Front Counter \u2014 Register 1", "description": "Android EMV COTS device", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv", "mid": "445566778899", "tid": "TERM0001", "store_number": "SN-COTS-00481", "bin": "412345", "vid": null, "agent_bank_number": null, "merchant_category_code": "5812", "terminal_capability": "5", "currency_code": "USD", "country_code": "US", "processor_config_id": "pcfg_4Df7Gh1Jk8Lm", "status": "active", "created_at": "2026-01-15T09:30:00Z", "deleted_at": null, "updated_at": null, "merchant_name": "Blue Bottle Coffee", "transactions": [], "surcharge_rate": null, "surcharge_confirmation_required": null, "surcharge_basis": null, "tax_rate": null, "tax_basis": null, "var_sheet": null, "batching": null } ``` ### terminal.updated A terminal's configuration was updated. ```json { "terminal_id": "term_5Hj3Kl8Mn2Pq6Rt", "name": "Front Counter \u2014 Register 2", "description": "Android EMV COTS device", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv", "mid": "445566778899", "tid": "TERM0001", "store_number": "SN-COTS-00481", "bin": "412345", "vid": null, "agent_bank_number": null, "merchant_category_code": "5812", "terminal_capability": "5", "currency_code": "USD", "country_code": "US", "processor_config_id": "pcfg_4Df7Gh1Jk8Lm", "status": "active", "created_at": "2026-01-15T09:30:00Z", "deleted_at": null, "updated_at": null, "merchant_name": "Blue Bottle Coffee", "transactions": [], "surcharge_rate": null, "surcharge_confirmation_required": null, "surcharge_basis": null, "tax_rate": null, "tax_basis": null, "var_sheet": null, "batching": null } ``` ### terminal.blocked A terminal was blocked (deactivated). ```json { "terminal_id": "term_5Hj3Kl8Mn2Pq6Rt", "name": "Front Counter \u2014 Register 1", "description": "Android EMV COTS device", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv", "mid": "445566778899", "tid": "TERM0001", "store_number": "SN-COTS-00481", "bin": "412345", "vid": null, "agent_bank_number": null, "merchant_category_code": "5812", "terminal_capability": "5", "currency_code": "USD", "country_code": "US", "processor_config_id": "pcfg_4Df7Gh1Jk8Lm", "status": "blocked", "created_at": "2026-01-15T09:30:00Z", "deleted_at": null, "updated_at": null, "merchant_name": "Blue Bottle Coffee", "transactions": [], "surcharge_rate": null, "surcharge_confirmation_required": null, "surcharge_basis": null, "tax_rate": null, "tax_basis": null, "var_sheet": null, "batching": null } ``` ### terminal.unblocked A blocked terminal was reactivated. ```json { "terminal_id": "term_5Hj3Kl8Mn2Pq6Rt", "name": "Front Counter \u2014 Register 1", "description": "Android EMV COTS device", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv", "mid": "445566778899", "tid": "TERM0001", "store_number": "SN-COTS-00481", "bin": "412345", "vid": null, "agent_bank_number": null, "merchant_category_code": "5812", "terminal_capability": "5", "currency_code": "USD", "country_code": "US", "processor_config_id": "pcfg_4Df7Gh1Jk8Lm", "status": "active", "created_at": "2026-01-15T09:30:00Z", "deleted_at": null, "updated_at": null, "merchant_name": "Blue Bottle Coffee", "transactions": [], "surcharge_rate": null, "surcharge_confirmation_required": null, "surcharge_basis": null, "tax_rate": null, "tax_basis": null, "var_sheet": null, "batching": null } ``` ### terminal.deleted A terminal was deleted (soft-deleted). ```json { "terminal_id": "term_5Hj3Kl8Mn2Pq6Rt", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv" } ``` ## Location Events ### location.created A new location was created. ```json { "id": "loc_7Bd4Cf9Gh2Jk5Lm", "name": "Ferry Building", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv", "address": { "street_line1": "1500 Market St", "street_line2": "Suite 200", "city": "San Francisco", "state": "CA", "zip": "94103", "country": "US" }, "phone": "+14155551234", "email": "ferry@bluebottle.example", "status": "active", "processor_config_id": "pcfg_4Df7Gh1Jk8Lm", "country_code": "US", "currency": "USD", "terminal_id": "term_5Hj3Kl8Mn2Pq6Rt", "metadata": null, "surcharge_rate": null, "surcharge_confirmation_required": null, "surcharge_basis": null, "tax_rate": null, "tax_basis": null, "created_at": null, "updated_at": null, "deleted_at": null } ``` ### location.updated A location's details were updated. ```json { "id": "loc_7Bd4Cf9Gh2Jk5Lm", "name": "Ferry Building Plaza", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv", "address": { "street_line1": "1500 Market St", "street_line2": "Suite 200", "city": "San Francisco", "state": "CA", "zip": "94103", "country": "US" }, "phone": "+14155551234", "email": "ferry@bluebottle.example", "status": "active", "processor_config_id": "pcfg_4Df7Gh1Jk8Lm", "country_code": "US", "currency": "USD", "terminal_id": "term_5Hj3Kl8Mn2Pq6Rt", "metadata": null, "surcharge_rate": null, "surcharge_confirmation_required": null, "surcharge_basis": null, "tax_rate": null, "tax_basis": null, "created_at": null, "updated_at": null, "deleted_at": null } ``` ### location.blocked A location was blocked (deactivated). ```json { "id": "loc_7Bd4Cf9Gh2Jk5Lm", "name": "Ferry Building", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv", "address": { "street_line1": "1500 Market St", "street_line2": "Suite 200", "city": "San Francisco", "state": "CA", "zip": "94103", "country": "US" }, "phone": "+14155551234", "email": "ferry@bluebottle.example", "status": "blocked", "processor_config_id": "pcfg_4Df7Gh1Jk8Lm", "country_code": "US", "currency": "USD", "terminal_id": "term_5Hj3Kl8Mn2Pq6Rt", "metadata": null, "surcharge_rate": null, "surcharge_confirmation_required": null, "surcharge_basis": null, "tax_rate": null, "tax_basis": null, "created_at": null, "updated_at": null, "deleted_at": null } ``` ### location.unblocked A blocked location was reactivated. ```json { "id": "loc_7Bd4Cf9Gh2Jk5Lm", "name": "Ferry Building", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv", "address": { "street_line1": "1500 Market St", "street_line2": "Suite 200", "city": "San Francisco", "state": "CA", "zip": "94103", "country": "US" }, "phone": "+14155551234", "email": "ferry@bluebottle.example", "status": "active", "processor_config_id": "pcfg_4Df7Gh1Jk8Lm", "country_code": "US", "currency": "USD", "terminal_id": "term_5Hj3Kl8Mn2Pq6Rt", "metadata": null, "surcharge_rate": null, "surcharge_confirmation_required": null, "surcharge_basis": null, "tax_rate": null, "tax_basis": null, "created_at": null, "updated_at": null, "deleted_at": null } ``` ### location.deleted A location was deleted (soft-deleted). ```json { "id": "loc_7Bd4Cf9Gh2Jk5Lm", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv" } ``` ## API Key Events ### api\_key.created A new [API key](/docs/appendix/glossary#api-key "Secret header credential for REST calls; v5 keys use scoped permissions.") was issued. ```json { "id": "key_1Ab2Cd3Ef4Gh5Ij", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv", "name": "Production integration", "status": "active", "key": "****4x9Q", "key_last4": "4x9Q", "expires_at": null, "created_at": "2026-01-15T09:30:00Z" } ``` ### api\_key.revoked An API key was revoked (denied at auth until reinstated). ```json { "id": "key_1Ab2Cd3Ef4Gh5Ij", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv", "name": "Production integration", "status": "revoked", "key": "****4x9Q", "key_last4": "4x9Q", "expires_at": null, "created_at": "2026-01-15T09:30:00Z" } ``` ### api\_key.reinstated A previously revoked API key was reinstated. ```json { "id": "key_1Ab2Cd3Ef4Gh5Ij", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv", "name": "Production integration", "status": "active", "key": "****4x9Q", "key_last4": "4x9Q", "expires_at": null, "created_at": "2026-01-15T09:30:00Z" } ``` ### api\_key.deleted An API key was deleted. ```json { "id": "key_1Ab2Cd3Ef4Gh5Ij", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv", "name": "Production integration", "status": "deleted", "key": "****4x9Q", "key_last4": "4x9Q", "expires_at": null, "created_at": "2026-01-15T09:30:00Z" } ``` ## Credential Events ### credential.created A new merchant credential was created. ```json { "id": "cred_7Kl8Mn9Op0Qr1St", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv", "code": "bluebottle-ferry", "pin": "****", "is_active": true, "created_at": "2026-01-15T09:30:00Z" } ``` ### credential.blocked A merchant credential was blocked (deactivated). ```json { "id": "cred_7Kl8Mn9Op0Qr1St", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv", "code": "bluebottle-ferry", "pin": "****", "is_active": false, "created_at": "2026-01-15T09:30:00Z" } ``` ### credential.unblocked A blocked merchant credential was reactivated. ```json { "id": "cred_7Kl8Mn9Op0Qr1St", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv", "code": "bluebottle-ferry", "pin": "****", "is_active": true, "created_at": "2026-01-15T09:30:00Z" } ``` ### credential.deleted A merchant credential was deleted. ```json { "id": "cred_7Kl8Mn9Op0Qr1St", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv", "code": "bluebottle-ferry", "pin": "****", "is_active": false, "created_at": "2026-01-15T09:30:00Z" } ``` ## Event Processing ### Identifying the event Because the event type is **not** in the body, don't `switch` on a body field. Instead: 1. **Recommended — subscribe per event type.** Point one endpoint at each event type (or category) you care about in the developer portal. The endpoint's URL then tells you what it received. 2. **In-body discriminators.** Transactions carry `transaction_type` + `status`; batches carry `status`. Lifecycle events for accounts, terminals, locations, API keys, and credentials do **not** carry the verb in the body (`terminal.created` and `terminal.updated` have identical shapes), so rely on the endpoint subscription for those. ### Idempotency Delivery is **at-least-once** — the same message may arrive more than once (e.g. on retry). **Deduplicate on the `svix-id` header**: it is the canonical delivery id and stays _constant across every retry_ of a message, so it's the key to track processed events (store it, e.g. in Redis with a 24-hour expiry, and skip anything you've already seen). The transaction `event_id` field is a separate, application-level correlation id for the underlying transaction event — useful for tying a webhook back to a transaction, but **not** the delivery-dedup key. ### Event ordering Events are generally delivered in the order they occurred, but retries can cause out-of-order delivery. Use the timestamps in the payload / `svix-timestamp` header if you need strict ordering. ### Event filtering Configure filters per endpoint in the developer portal: * **Event type** — subscribe to specific event types (recommended for production). * **Transaction type** — branch on `transaction_type` (`sale`, `auth`, `capture`, `refund`, `reverse`, `tip_adjust`, `incremental_auth`). * **Status** — branch on `status` (`authorized`, `captured`, `declined`, `refunded`, `reversed`, …). * **Processor** — branch on `processor` (`tsys`, `payroc`, …). ## Complete Event List | Event | Description | Category | | -------------------------- | ------------------------------------------------------------------ | ----------- | | `transaction.authorize` | Card authorized — funds held, not yet captured. | transaction | | `transaction.sale` | Sale — card authorized and captured in one step. | transaction | | `transaction.capture` | A prior authorization was captured for settlement. | transaction | | `transaction.cancel` | Transaction cancelled before settlement. | transaction | | `transaction.create` | A transaction record was created. | transaction | | `transaction.reverse` | Authorization voided/undone after auth, before settlement. | transaction | | `transaction.refund` | Funds refunded to the cardholder after settlement. | transaction | | `transaction.increment` | An existing authorization amount was increased (incremental auth). | transaction | | `transaction.tip_adjust` | A tip was added or changed on a captured transaction. | transaction | | `batch.opened` | A settlement batch was opened. | batch | | `batch.rejected` | The processor rejected the batch. | batch | | `batch.accepted` | The processor accepted the batch for settlement. | batch | | `batch.partially_accepted` | The processor accepted the batch with some rejected records. | batch | | `batch.edited` | A batch's transactions were edited before close. | batch | | `batch.submitted` | A batch was submitted to the processor for settlement. | batch | | `account.created` | A new account was created. | account | | `account.updated` | An account's details were updated. | account | | `account.blocked` | An account was blocked (deactivated). | account | | `account.unblocked` | A blocked account was reactivated. | account | | `account.deleted` | An account was deleted (soft-deleted). | account | | `terminal.created` | A new terminal was created. | terminal | | `terminal.updated` | A terminal's configuration was updated. | terminal | | `terminal.blocked` | A terminal was blocked (deactivated). | terminal | | `terminal.unblocked` | A blocked terminal was reactivated. | terminal | | `terminal.deleted` | A terminal was deleted (soft-deleted). | terminal | | `location.created` | A new location was created. | location | | `location.updated` | A location's details were updated. | location | | `location.blocked` | A location was blocked (deactivated). | location | | `location.unblocked` | A blocked location was reactivated. | location | | `location.deleted` | A location was deleted (soft-deleted). | location | | `api_key.created` | A new API key was issued. | api\_key | | `api_key.revoked` | An API key was revoked (denied at auth until reinstated). | api\_key | | `api_key.reinstated` | A previously revoked API key was reinstated. | api\_key | | `api_key.deleted` | An API key was deleted. | api\_key | | `credential.created` | A new merchant credential was created. | credential | | `credential.blocked` | A merchant credential was blocked (deactivated). | credential | | `credential.unblocked` | A blocked merchant credential was reactivated. | credential | | `credential.deleted` | A merchant credential was deleted. | credential | ## See also * [Setting up Webhooks](/docs/webhooks/setting-up-webhooks) — configuring endpoints, event subscriptions, and signature verification # Online PIN Validation Koard supports **[online PIN](/docs/appendix/glossary#online-pin "PIN validated in real time by the issuer during authorization (not offline PIN).") only**. When a PIN is required, it is validated in real time against the issuer during the authorization request — Koard does not support offline PIN, where the PIN would be verified locally on the device without issuer involvement. The encrypted PIN block is forwarded to the acquirer and issuer as part of the authorization. The issuer validates the PIN against the cardholder's account and will hard decline the transaction if the PIN is incorrect — this decision is made entirely by the issuer and acquirer, not by Koard. When a cardholder enters a PIN during a [Tap to Pay on iPhone](/docs/appendix/glossary#tap-to-pay "Apple contactless acceptance on iPhone without extra hardware.") transaction, Apple encrypts the PIN data before it leaves the device. Koard handles the full decryption and validation flow online — the encrypted PIN never passes through your application unprotected. ## How It Works 1. The iOS SDK captures the cardholder's PIN and returns encrypted cardholder data, encrypted PIN data, and a transaction ID to Koard. 2. Koard calls Apple's [Proximity Payment Service](/docs/appendix/glossary#proximity-payment-service "Apple service that exchanges encrypted Tap to Pay data for single-use decryption keys.") to exchange the encrypted data for single-use decryption keys. 3. Apple returns keys scoped to that transaction. Koard validates and decrypts the data, then forwards the PIN block to the payment processor in the authorization request. ## Supported Scenarios | Scenario | PIN Captured | Notes | | --------------------------- | ------------ | ------------------------------------------------------------ | | Cardholder data only | No | Standard contactless — no PIN required | | Cardholder data + PIN | Yes | PIN collected inline during the tap | | Cardholder data + PIN token | Yes | PIN collected and tokenized | | PIN fallback | Yes | Used when the card requires PIN but cannot use standard flow | # Glossary Canonical definitions for Koard docs. Hover a linked term in any guide for a short definition, or jump here for the full entry. ## Platform ### Koard Payment platform that helps PSPs and ISVs accept Tap to Pay on iPhone and card-present payments via SDKs, REST APIs, and merchant management. ### PSP (Payment Service Provider) Company that serves multiple merchants with payment processing. A primary Koard customer type. ### ISV (Independent Software Vendor) Software company building payment or POS applications that integrate Koard for Tap to Pay and processing. ### Partner Top-level account in the MMS that owns descendant merchant accounts, terminals, and API keys. ### MMS (Merchant Management System) Koard portal for onboarding merchants, terminals, locations, credentials, and API keys. * **UAT:** * **Production:** ### UAT Isolated sandbox environment (`api.uat.koard.com` / `app.uat.koard.com`). Transactions do not move money or appear in live card or merchant histories. ### Sandbox Koard test environment for exercising features without live funds. Distinct from an Apple Sandbox Apple Account used for Tap to Pay certification. ### Production / Live mode Live environment where activated merchant accounts accept real payments (`api.koard.com` / `app.koard.com`). ## Accounts & hierarchy Tax and surcharge defaults resolve in this order: **location → terminal → account**. The first non-null value wins; `null` inherits and explicit zero overrides. ### Account Merchant or partner entity in Koard. ### Merchant Business onboarded under a partner to accept payments. Creation requires a name, tax ID, MCC, and a US business address. Country must remain US on address updates; a merchant update cannot move the account to an unsupported jurisdiction. Tax ID and merchant payment defaults do not belong in partner creation. See [Creating Merchant and Partner Accounts](/docs/getting-started-with-koard/creating-accounts). ### Location Physical or logical place under a merchant where terminals accept payments. Can override account-level tax and surcharge settings. ### location\_id Unique identifier for a Location. Used in REST paths (`/v1/locations/{location_id}`), list filters, webhook payloads, and SDKs (for example `setActiveLocation(locationId)` before `enrollDevice` / payments). ### Terminal Payment endpoint (often an iPhone running Tap to Pay) tied to processor credentials (MID/TID or Partner ID) and optionally a terminal profile. ### Terminal Profile Reusable settings for terminals: tipping, surcharge flags, receipts, timeouts, and UI preferences. ### Merchant Credentials Credentials merchants use to authenticate the Koard SDK and process payments; scoped to locations and terminals. ### MCC (Merchant Category Code) Four-digit ISO business-type code required at merchant onboarding (for example `5812` for restaurants). ## Payment operations **Tap-initiated (card-present):** Sale and Preauth require encrypted card data from Tap to Pay or another compliant reader. **Follow-ups (card-not-present):** Capture, Incremental Auth, Tip Adjust, Reverse, Refund, and Confirm do not require another tap. ### Sale One-step authorize + capture. Funds are collected immediately; status typically ends as `captured`. ### Preauth (Preauthorization) Authorization hold without capture. Use when the final total may change; you must later capture or reverse. ### Capture Settle (collect) funds from a prior authorization. Optional amount and breakdown for tips and surcharge reconciliation. ### Incremental Auth Increase an existing authorization hold by a delta amount. Status remains `authorized`. A declined increment reverts to the last successfully authorized amount. ### Tip Adjust Update gratuity before settlement (for example tip-on-receipt). Does not recompute surcharge. ### Reverse / Void Release uncaptured authorized funds. Use instead of refund when money was never captured. ### Refund Return funds on a captured transaction (full or partial). Distinct from reverse. ### Confirm SDK/API step that finalizes a sale after cardholder surcharge disclosure when the SDK status is `surchargePending` (webhook/API payloads use `surcharge_pending`). ### PaymentBreakdown SDK object for subtotal, tax, tip, and nested Surcharge details sent with payment requests. ### Surcharge Fee added to eligible credit-card transactions to offset processing costs. Not applied to debit. Can be automatic (processor) or custom (for example BIN-based). ### surchargePending SDK sale status when automatic surcharge applies and cardholder confirmation is required before finalization. ### surcharge\_pending Webhook and REST API transaction status when surcharge confirmation is required (`surcharge_pending`). Distinct from the SDK enum value `surchargePending`. ### Tip capture mode How tip was collected: `tip_at_sale` (on the original sale) or `tip_adjust` (added or changed after the sale). ### Tax rate / Tax basis Hierarchical configuration defaults for what tax is calculated on and at what percentage. Separate from per-transaction `taxAmount` / `taxRate` on the breakdown. ## Identifiers Do not conflate these IDs. ### event\_id Client-generated UUID4 idempotency key for a single API attempt. Replays with the same ID are rejected as duplicates. Similar role to RRN for one processor call. ### transaction\_id Koard-issued ID for the full payment lifecycle. Shared across capture, tip adjust, incremental auth, reverse, and refund on the same payment. ### MID (Merchant ID) Processor-assigned merchant identifier used for routing and batching (often with TID). ### TID (Terminal ID) Processor-assigned per-terminal identifier. Batches are typically unique per MID/TID combination. ### VIN (Vendor ID) Vendor identifier from some processors, entered with MID/TID at terminal setup. Some processors use Partner ID instead. ### Partner ID / Processing Terminal ID Alternate boarding identifier used by processors like Payroc in place of classic MID/TID/VIN. ### processor\_batch\_id Batch ID unique per MID/TID used when informing Koard of settlements from an external batching system. ### BIN Bank Identification Number (card prefix). Used for custom BIN-based surcharging and (as Acquirer BIN) on some processor VAR sheets such as TSYS. ### DBA Doing Business As — the trade name shown on cardholder statements and processor records (for example `doing_business_as_name` on Silverflow). ### RRN (Retrieval Reference Number) Traditional card-network identifier for a single processor attempt; conceptually similar to `event_id`. ## Transaction states These are REST status values. SDK enums can additionally represent reader and UI states. See [Payment Lifecycle](/docs/payments/payment-lifecycle). ### pending Outcome or external input is still pending. Reconcile before another operation. ### surcharge\_pending Cardholder surcharge confirmation is required. ### authorized Funds are held, awaiting supported capture or reversal. ### captured Sale or capture succeeded; settlement follows. ### settled Settlement was recorded. ### declined The attempt was declined. ### error An error was recorded. Reconcile any uncertain processor outcome before resubmitting. ### reversed A reversal succeeded; hold release timing depends on the issuer. ### refunded A refund succeeded; inspect amounts for partial refunds. ### canceled The payment flow was canceled. ## Boarding & processors ### Boarding Onboarding a merchant or terminal onto a payment processor with credentials from a VAR sheet (via MMS UI or API). ### VAR Sheet Processor-provided parameter packet (MID, TID, MCC, Group ID, and related fields) required to board a terminal. Shape differs by processor (TSYS, Fiserv, Elavon, Worldpay, Payroc, Silverflow, Cybersource). ### Processor Config Koard-side configuration selecting which acquiring platform or flavor a merchant uses (for example Fiserv Rapid Connect front-ends via `processor_config_id`). ### Host capture Settlement model where the processor host manages the batch and settles on its cutoff (for example Fiserv today). Merchant-driven open/close may be unavailable. ### TPPID Fiserv Rapid Connect ID for the certified integrator/SDK build (owned by Koard), not a merchant VAR-sheet field. ### Datawire / DID Transport/ID used for some Fiserv terminals when Datawire is enabled; may be on the VAR packet or provisioned by Koard. ### Passthrough EMV Flow where Koard only forwards EMV data and later receives the final outcome from the PSP. `PUT /v1/transactions/{id}` is reserved for this; not for directly managed processors such as TSYS, Elavon, Fiserv, or Worldpay. ## Apple & Tap to Pay ### Tap to Pay on iPhone Apple contactless acceptance on iPhone (XS or later, iOS 17.4+) without extra hardware. Requires entitlement, provisioning, and Koard SDK integration. ### Tap to Pay on Android Contactless card acceptance on NFC-enabled Android devices (Android 13+) using the Visa Tap to Pay Ready kernel and Koard SDK — no external reader hardware required. ### Tap to Pay Entitlement Apple Managed Capability that must be requested and approved before building Tap to Pay–enabled apps. ### L3 Certification Apple Level-3 certification path that Koard’s partnership helps PSPs and ISVs bypass for faster time-to-market. ### Sandbox Apple Account Dedicated Apple tester account signed in on a test iPhone for certification and QA. Production Apple IDs cannot run Sandbox Tap to Pay. ### Apple CERT environment Apple certification environment used after development certificate approval when validating Tap to Pay flows. ### Apple Business Register Apple hub for PSP Tap to Pay documentation, registration, and certification resources (PSP access). ### Proximity Payment Service Apple service Koard calls to exchange encrypted Tap to Pay cardholder/PIN data for single-use decryption keys. ### KEK exchange Key-encryption-key setup process referenced in Apple partnership and environment configuration phases. ### prepare() SDK call that prepares the device/session for Tap to Pay before running payments; required for successful taps. ### Online PIN PIN validated in real time by the issuer during authorization. Koard supports online PIN only (not offline PIN). ## Settlement ### Batch Collection of transactions grouped for settlement, typically per MID/TID. Can be opened, edited, submitted, accepted, or rejected. ### Batch cutoff / Batch window Time period when transactions are included in a batch, and the time when the batch closes for settlement. ### Settlement Transfer of funds from the payment processor to the merchant account after batch processing. ### Automated Batch Scheduling Koard-managed timing and retries for opening and closing batches when Koard is the authorization layer. ## API & security ### API Key Secret header credential for REST calls (`x-koard-apikey`). v5 keys use explicit scoped permissions (`resource:action`). Partner keys can manage sub-accounts; merchant keys are limited. ### Scoped Permissions Explicit grants on v5 API keys (for example `payments:capture`). Holding a write permission does not imply the matching read permission. Macros: `all` and `legacy_all`. ### Webhook HTTPS POST callback from Koard when events occur. Verify the HMAC-SHA256 signature, respond with HTTP 200 quickly, and handle deliveries idempotently. Transaction events fire on the attempt — read `status` and `status_reason` for the outcome. ### SVIX Webhook infrastructure provisioned with v2 account creation alongside the account organization in MMS. REST API keys are issued separately. ### Idempotency Safe retry pattern using a client-generated `event_id` so network failures do not create duplicate charges. Lookup: `GET /v1/transactions/event/{event_id}`. ### PCI DSS Payment Card Industry Data Security Standard. Koard is built to meet PCI DSS practices including encryption and tokenization. ### Tokenization Replacing sensitive payment data with tokens to reduce PCI scope and exposure. # Developing with Apple Comprehensive guidelines and best practices for developing [Tap to Pay on iPhone](/docs/appendix/glossary#tap-to-pay "Apple contactless acceptance on iPhone without extra hardware.") applications with Koard and Apple's payment technologies. ## Overview Developing Tap to Pay on iPhone applications requires careful attention to security, environment configuration, and compliance with Apple's requirements. This guide covers essential security measures, environment setup, and testing procedures to ensure a secure and compliant integration. ## Security Considerations ### Cardholder Data Protection Given the sensitive nature of cardholder data processed through Tap to Pay on iPhone, Koard has implemented multiple security measures to ensure secure development and operation: * **Secure SDK Architecture**: All card data is handled securely through Apple's ProximityReader framework * **Encrypted Communication**: All API communications use industry-standard encryption * **[Tokenization](/docs/appendix/glossary#tokenization "Replacing sensitive payment data with tokens to reduce PCI scope.")**: Sensitive payment data is tokenized and never stored in plain text * **[PCI DSS](/docs/appendix/glossary#pci-dss "Payment Card Industry Data Security Standard for cardholder data protection.") Compliance**: Koard maintains PCI DSS Level 1 compliance standards ### Entitlement Verification Before initiating any Tap to Pay functionality, verify that your application has the correct Tap to Pay on iPhone entitlement. **Check Entitlement Status** Configure the entitlement and provisioning for the app's bundle ID in Xcode and Apple Developer. Test reader preparation on a supported physical iPhone after initialization, merchant login, and active-location selection. Handle linking and preparation errors through the SDK; hardware support alone does not verify an entitlement. See [Adding Tap to Pay](/docs/setting-up-the-ios-sdk/adding-support-for-tap-to-pay-on-iphone) and [Running Payments](/docs/setting-up-the-ios-sdk/running-payments). ## Environment Configuration ### UAT and Production Environments Use the SDK's `.uat` environment with a UAT SDK client key for integration testing, and `.production` with the matching production key for live merchant payments. The published SDK also supports `.custom(String)` when Koard supplies a specific backend URL. It does not expose a `.cert` enum case. Apple sandbox account setup and app entitlements are separate from Koard's backend environment selection. Use a dedicated test device and the [Sandbox Apple Account guide](/docs/setting-up-the-ios-sdk/creating-a-sandbox-apple-account) for UAT Tap to Pay tests. ### Environment Selection To switch between environments, configure your SDK initialization: ```swift let options = KoardOptions( environment: .uat, // .production for live merchant payments loggingLevel: .debug ) KoardMerchantSDK.shared.initialize( options: options, apiKey: "your-api-key" ) ``` ## Check merchant linking status Use `GET /v1/accounts/{account_id}/linkstatus` to read the merchant's current Apple Tap to Pay linking status. Supply the merchant's Koard account ID, rather than the processor's MID or an Apple ID. Your API key needs `accounts:read` and access to that merchant account. The merchant itself and its account ancestors can query it. A PSP account is not a valid target. ```sh curl 'https://api.uat.koard.com/v1/accounts/YOUR_MERCHANT_ACCOUNT_ID/linkstatus' \ -H 'X-Koard-apikey: YOUR_API_KEY' ``` The response is always an array, including when there is just one configured provider: ```json [ { "account_id": "YOUR_MERCHANT_ACCOUNT_ID", "merchant_name": "Merchant Coffee Shop", "iso_name": "koard", "status": "LINKED", "linked": true, "created_date": null } ] ``` | `status` | `linked` | Meaning | | ------------- | -------- | ------------------------------------------------------ | | `LINKED` | `true` | Apple reports that the merchant is linked. | | `NOT_LINKED` | `false` | Apple reports that the merchant is not linked. | | `DEACTIVATED` | `false` | Apple reports that the merchant's link is deactivated. | `merchant_name` is the target merchant's name. `iso_name` identifies the configured PSP. Multiple entries are possible. The call reads current status and does not link, relink, or accept terms for the merchant. An empty array means associated configurations exist but none has management credentials available for a lookup. It does not mean Apple returned `NOT_LINKED`. If there is no associated active configuration, the API returns 503. A missing or inaccessible merchant returns 404; a non-merchant target returns 400. Lookup failures return an error instead of a partial array or a fabricated status. See the [Accounts API reference](/api-reference/accounts) for the response schema and error codes. This merchant linking status is separate from a PSP's Apple configuration and reporting inventory. ## Sandbox Testing ### Sandbox Tester Account Setup If your SDK or API developers need to connect to Apple's sandbox through your UAT integration, you must create a Sandbox Tester Account. This account allows you to test Tap to Pay functionality without processing real transactions. ### Creating a Sandbox Tester Account Follow these steps to create a sandbox tester account: 1. **Sign in to App Store Connect** * Navigate to [App Store Connect](https://appstoreconnect.apple.com) * Sign in with your Apple Developer account credentials 2. **Access Sandbox Testers** * On the homepage, click **Users and Access** * In the top navigation, click **Sandbox** * Click the add button (+) * If this is your first time adding sandbox testers, click **Create Test Accounts** 3. **Complete Tester Information** * Enter a first and last name for your tester * Enter an email address that: * Has not been used as an Apple Account * Has not been used to purchase iTunes or App Store content * Consider creating a dedicated email address for each sandbox tester * Enter a strong password that meets Apple's requirements * Choose an App Store country or region 4. **Email Subaddressing (Optional)** If your email service provider supports email subaddressing with a plus sign (+), you can use subaddresses of a sandbox-specific address for multiple testers. For example: * Base email: `billjames2@icloud.com` * Subaddresses: `billjames2+UK@icloud.com`, `billjames2+US@icloud.com`, `billjames2+JP@icloud.com` All communications sent to the subaddresses are also sent to the base address. 5. **Invite the Tester** * Review all information * Click **Invite** to complete the setup 6. **Configure Testing Devices** * Sign out of your Apple Account on all testing devices * Sign back in with your new sandbox tester account **Important Notes**: * Once you create a tester, you cannot edit the name, email, or password * Each test account is associated with one of 175 App Store storefronts * You can edit a tester's App Store country or region after creation to test on different storefronts using the same Sandbox account ### Additional Resources For more detailed information on creating sandbox tester accounts, see [App Store Connect Help: Create a sandbox tester account](https://help.apple.com/app-store-connect/#/dev8b57d558e). ## Best Practices ### Development Workflow 1. **Use UAT** for all internal development and testing 2. **Verify Entitlements** before attempting to use Tap to Pay functionality 3. **Implement Error Handling** for missing entitlements and other error conditions 4. **Test Thoroughly** using sandbox tester accounts before production deployment ### Security Guidelines * Never log or store sensitive cardholder data * Implement proper error handling without exposing sensitive information * Use secure communication protocols (HTTPS/TLS) * Follow Apple's security guidelines for payment applications * Regularly update your SDK to the latest version for security patches ## Testing Checklist Before deploying to production, ensure: * [ ] Application has Tap to Pay on iPhone entitlement configured * [ ] Entitlement verification is implemented and tested * [ ] Error handling for missing entitlements is in place * [ ] Sandbox tester accounts are created and configured * [ ] Testing is performed in UAT * [ ] All payment flows are tested with test cards * [ ] Production environment is properly configured before go-live ## Support and Resources ### Apple Documentation * [Apple Pay Developer Guide](https://developer.apple.com/apple-pay/) * [ProximityReader Framework Reference](https://developer.apple.com/documentation/proximityreader) * [App Store Connect Help](https://help.apple.com/app-store-connect/) * [Human Interface Guidelines](https://developer.apple.com/design/human-interface-guidelines/) ### Koard Resources * [Setting Up the Entitlement](/docs/setting-up-the-ios-sdk/setting-up-the-entitlement-for-tap-to-pay-on-iphone) * [SDK Installation Guide](/docs/setting-up-the-ios-sdk/installing-the-sdk) * [Payment Lifecycle Guide](/docs/payments/payment-lifecycle) * [Test Cards Reference](/docs/appendix/resources#test-cards) # Apple Best Practices and Guidelines ## Overview These integration patterns use the published **Koard iOS SDK 1.0.21** for ordinary merchant Tap to Pay checkout. Let the SDK manage Apple's reader; use its public readiness and payment methods in your app. ## Apple's Dual Review Process ### 1. Tap to Pay Review Follow the [entitlement setup guide](/docs/setting-up-the-ios-sdk/setting-up-the-entitlement-for-tap-to-pay-on-iphone) for your app's Tap to Pay access and provisioning. ### 2. App Store Review Use the [App Store submission guide](/docs/setting-up-the-ios-sdk/app-store-submission) to prepare the reviewer flow and the correct environment build. Consult Apple's current [App Review Guidelines](https://developer.apple.com/app-store/review/guidelines/) for submission requirements. ## User Experience Best Practices ### Feedback and User Actions Show the active merchant location in Settings and checkout. Require an explicit selection of a location belonging to the authenticated merchant with a usable linked terminal. Explain whether checkout is a sale or a preauth hold, show the final amount, and prevent duplicate payment actions while work is in progress. ### Error Handling Best Practices Keep background preparation feedback inline. An `accountNotLinked` error requires a visible terms-acceptance action; do not launch linking from a background retry. Handle customer cancellation separately from a decline or an unknown payment outcome. Preserve the saved operation ID for recovery after network failures. ## ProximityReader Framework Best Practices ### Reader Management Initialize once, log in, select the active location, and prepare before the first payment. The standard checkout demo reads `KoardMerchantSDK.shared.status`; the SDK owns the reader event subscription. Do not add a competing `readerEvents` iterator or construct a separate ProximityReader session alongside the SDK. A preparation coordinator keeps app-triggered preparation sequential and surfaces errors: ```swift import Combine import KoardSDK @MainActor final class PaymentReadiness: ObservableObject { @Published private(set) var isPreparing = false @Published private(set) var isReady = false @Published private(set) var needsAccountLinking = false @Published private(set) var message = "Reader not ready" func prepare() async { guard !isPreparing else { return } isPreparing = true defer { isPreparing = false } do { try await KoardMerchantSDK.shared.prepare() let status = KoardMerchantSDK.shared.status isReady = status.isReady needsAccountLinking = false message = status.info } catch KoardMerchantSDKError.accountNotLinked { isReady = false needsAccountLinking = true message = "Accept Tap to Pay terms to continue" } catch { isReady = false message = error.localizedDescription } } // Call only from the operator's explicit terms-acceptance action. func acceptTerms() async { guard !isPreparing else { return } isPreparing = true do { try await KoardMerchantSDK.shared.linkAccountAsync() isPreparing = false await prepare() } catch { isPreparing = false isReady = false needsAccountLinking = true message = error.localizedDescription } } } ``` Call these methods after login and location selection. Monitor public status while the view is visible using the [cancelable polling example](/docs/setting-up-the-ios-sdk/running-payments#session-management). Disable checkout during preparation, merchant/location changes, and SDK cleanup. ### Secure Payment Processing Use the SDK's `sale` and `preauth` calls to obtain the encrypted reader data and route the request. Amount arguments are integer minor currency units. Inspect `response.transaction` before displaying success; partial approval, surcharge confirmation, and pending outcomes need separate handling. See [Running Payments](/docs/setting-up-the-ios-sdk/running-payments). ## Security Best Practices ### Data Protection and Privacy Persist order references, operation IDs, transaction IDs, and approved receipt data needed by your app. Keep privileged API keys on your backend. Never put card data, PINs, session tokens, or credentials in metadata or application logs. Use the intended scoped SDK client key in the mobile app. ### Entitlement Verification Configure Tap to Pay entitlements and provisioning for the app's bundle ID. Test on a supported physical iPhone; a successful simulator build does not validate a reader entitlement or a real tap. `isReaderSupported` describes reader support, not proof that merchant linking and preparation succeeded. ## UI/UX Design Guidelines ### Payment Flow Design Keep the amount, currency, active location, and sale/preauth mode visible. Obtain any required surcharge decision using the returned disclosure and final total. Show partial approval before collecting the remainder on another card. An authorized hold must not be presented as captured or settled. ### Progress and Loading States Display preparation and transaction progress without blocking the main thread. Keep the payment button disabled during active work. Poll readiness only while the view is visible and cancel the polling task on navigation; do not accumulate untracked event listeners. ## Testing and Validation ### Comprehensive Testing Strategy Exercise login, merchant changes, invalid saved locations, reader preparation, declined linking, sale, preauth, partial approval, surcharge decisions, refunds, and cancellation. Test uncertain outcomes and transaction recovery without automatically submitting a second charge. Use the correct environment and test cards on physical hardware. ### TestFlight Preparation Archive with the intended environment scheme and matching SDK client key. Verify the same signed build, entitlement, merchant location, and reader flow that testers will use. ## App Store Review Preparation ### Documentation Requirements Provide clear reviewer steps for authentication, location selection, terms acceptance, and checkout. Explain any test-account or device setup needed to reach those screens. See [App Store submission](/docs/setting-up-the-ios-sdk/app-store-submission). ### Review Checklist - The archive uses the intended environment and provisioning. - The active location and transaction amount are visible before payment. - Linking, preparation, cancellation, and decline flows provide actionable feedback. - Receipts reflect the returned transaction state and actual amounts. - Testing includes a supported physical device. ## Performance Optimization ### Memory Management Cancel screen-owned monitoring tasks when leaving the screen. Reuse the SDK singleton and let it own reader sessions. Complete active payment/preparation work before logout or deinitialization. ### Network Optimization Prepare after merchant and location setup to move reader readiness work ahead of checkout. Reuse the saved operation ID for supported outcome recovery; do not invent a new payment after a timeout. Backend-operation success must be determined from the returned transaction, not solely from a completed network call. ## Resources and References ### Apple Documentation - [ProximityReader](https://developer.apple.com/documentation/proximityreader) - [Tap to Pay on iPhone](https://developer.apple.com/tap-to-pay/) - [App Review Guidelines](https://developer.apple.com/app-store/review/guidelines/) ### Koard Resources - [Installing the SDK](/docs/setting-up-the-ios-sdk/installing-the-sdk) - [Running Payments](/docs/setting-up-the-ios-sdk/running-payments) - [Standard Demo App](/docs/setting-up-the-ios-sdk/demo) - [SDK Lifecycle](/docs/setting-up-the-ios-sdk/sdk-lifecycle) ### Additional Support Contact [Koard Developer Support](mailto:developers@koard.com) with the SDK version, environment, transaction/operation IDs, and a sanitized description of the failure. # Payment Configurations Payment routing comes from the active location's linked terminal and processor configuration. Choose a configuration available to the merchant in the same environment, then board the terminal using that configuration's required fields. - [List available processor configurations](/docs/boarding-a-merchant/processor-configurations). - [Board or update a terminal](/docs/boarding-a-merchant/contracts). - [Select the active location](/docs/getting-started-with-koard/setting-up-the-merchant#active-location-and-terminal-routing). - [Configure tax and tips](/docs/payments/tax-and-tip-handling). - [Configure surcharging](/docs/payments/surcharging). - [Understand batch management](/docs/batch-and-settlements/running-batches). Apple terminal profiles control reader behavior; a merchant's boarded terminal contains the processor routing and identifiers. Keep these distinct. For configuration changes requiring Koard assistance, contact [support@koard.com](mailto:support@koard.com). # Resources ## SDKs and Integration Guides | Resource | Link | |---|---| | iOS SDK releases and Swift Package Manager package | [koard-ios](https://github.com/koardlabs/koard-ios) | | iOS installation | [Installing the SDK](/docs/setting-up-the-ios-sdk/installing-the-sdk) | | Android SDK and examples | [koard-android](https://github.com/koardlabs/koard-android) | | Android installation and KiC package visibility | [Installing the SDK](/docs/setting-up-the-android-sdk/installing-the-sdk#step-2-configure-androidmanifest.xml-permissions-and-package-visibility) | | Merchant and location setup | [Merchant Setup](/docs/getting-started-with-koard/setting-up-the-merchant) | | Public REST contracts | [API Reference](/api-reference) | | Payments and recovery | [Payment Lifecycle](/docs/payments/payment-lifecycle) | | Settlement and batch reports | [Running Batches](/docs/batch-and-settlements/running-batches) | | Signed webhook handling | [Webhook Setup](/docs/webhooks/setting-up-webhooks) | Use Swift Package Manager with `https://github.com/koardlabs/koard-ios`, or the released XCFramework, for iOS. The current Android dependency is: ```kotlin implementation("com.koard:koard-android-sdk:1.0.7") ``` ## Test Cards COTS contactless acceptance requires actual reader-generated EMV data. Generic keyed PAN/CVV test numbers do not simulate a card tap and are not Koard contactless certification fixtures. Use the test cards, wallet tokens, and test cases assigned by your processor or network for the selected environment and certification project. Follow [Apple Pay sandbox testing](https://developer.apple.com/apple-pay/sandbox-testing/) when provisioning a supported sandbox wallet. Confirm supported brands, card types, PIN/CVM cases, and expected responses with your certification contact. Do not substitute data from another processor's test suite. ## Environments | Environment | API URL | Use | |---|---|---| | UAT | `https://api.uat.koard.com` | Integration testing with the assigned UAT processor configuration and test credentials | | Production | `https://api.koard.com` | Approved live merchant configurations | Keep environment-specific keys, merchant identifiers, and processor configuration IDs together. A processor's separate CERT credentials are not interchangeable with Koard UAT credentials. ## Validation Checklist - Select the active merchant location in app settings and verify its linked terminal. - Test approved, declined, partial approval, cancellation, and supported refund/tip flows using assigned test cases. - Verify readiness, supported device eligibility, and the Android KiC package lookup. - Recover lost responses using a persisted event ID instead of blindly retrying. - Verify webhook signatures and duplicate delivery handling. - Reconcile transaction totals against the batch submission and processor report. - Complete the platform and processor approvals required for production. ## Primary Platform References - [Apple Tap to Pay on iPhone](https://developer.apple.com/tap-to-pay/) - [Apple ProximityReader](https://developer.apple.com/documentation/proximityreader) - [Android package visibility](https://developer.android.com/training/package-visibility/declaring) - [Svix signature verification](https://docs.svix.com/receiving/verifying-payloads/how) For merchant configuration or certification questions, contact [support@koard.com](mailto:support@koard.com). # Koard API Reference Koard API provides unified access to account onboarding, terminal management, payment facilitation, and reporting across all Koard environments. ## Base URLs | Environment | URL | | -------------------------------------------------------------------------------------------------------- | --------------------------- | | [UAT](/docs/appendix/glossary#uat "Isolated sandbox environment — transactions do not move live money.") | `https://api.uat.koard.com` | | Production | `https://api.koard.com` | UAT is an isolated sandbox environment — transactions processed there will not appear in card or merchant histories and no money moves. ## API Versioning All Koard API endpoints are versioned with a path prefix (`/v1`, `/v2`, `/v3`, etc.). The version is part of the URL path and is **required** for every request. ```bash # Example: v1 endpoint curl https://api.koard.com/v1/accounts/{account_id} \ -H "x-koard-apikey: YOUR_API_KEY" # Example: v2 endpoint curl https://api.koard.com/v2/terminals \ -H "x-koard-apikey: YOUR_API_KEY" # Example: v3 endpoint curl https://api.koard.com/v3/payments/{transaction_id}/capture \ -H "x-koard-apikey: YOUR_API_KEY" ``` ### Current Versions by Resource | Resource | Version(s) | Path Prefix | Notes | | ------------------- | ---------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- | | **Accounts** | v1, v2 | `/v1/accounts`, `/v2/accounts` | Create and manage merchant and partner accounts; issue REST API keys separately through v5 | | **Terminals** | v2 | `/v2/terminals` | Paginated listing, search, and full CRUD | | **Locations** | v1 | `/v1/locations` | Create, update, and retrieve locations | | **Transactions** | v1, v2 | `/v2/transactions`, `/v1/transactions/{id}` | Use v2 GET for filtered, paginated searches; v1 retains transaction detail and passthrough-only edits | | **Payments** | v4 | SDK-managed card-present initiation | Released SDKs supply encrypted reader data and use the supported platform flow | | **Payment Actions** | v1, v3 | `/v1/payments/{id}/…`, `/v3/payments/{id}/…` | Capture, auth increment, refund, reverse, adjust, confirm | | **Refunds** | v1, v2 | `/v1/payments/{id}/refund`, `/v2/payments/{id}/refund` | Reference-based refund or supported card-present refund | | **Batches** | v1 | `/v1/batches` | Open, close, and edit settlement batches | | **API Keys** | v5 | `/v5/apikeys` | Scoped-permission keys; see [Authentication](/api-reference/authentication) | The `/v5` surface currently exposes **API-key management only** (`/v5/apikeys`). Other resources such as payments, terminals, and accounts are served by their documented `/v1` through `/v4` paths above; there is no `/v5/payments`, `/v5/terminals`, etc. The site version selector, API path versions, and SDK release versions are independent. The default site version contains the current references. Older selectable API references are historical; shared guide pages describe the current integration. Use the API path and SDK version shown in each example. Account creation has separate **merchant** and **partner** request variants. Merchants require `tax_id`, `mcc`, and a US business address; partners omit merchant tax and payment defaults. See [Creating Merchant and Partner Accounts](/docs/getting-started-with-koard/creating-accounts). ### Version Lifecycle * **Newer versions** may change request/response schemas, add required fields, or alter default behavior. Always use the version shown in the endpoint path. * **Older versions** remain functional unless an endpoint is explicitly retired, and may not include the latest features. We recommend migrating to the latest available version for each resource. * **Endpoint retirement** is a breaking change. The transaction-search retirement below identifies the removed operation and its replacement. > **Note:** The version prefix (e.g. `/v1/`, `/v2/`, `/v3/`) is required in all request paths. Requests without a version prefix will return a `404`. ### Transaction search Use `GET /v2/transactions` for filtered transaction searches. The former `POST /v1/transactions/search` operation is retired and returns `405` after the retirement release is deployed. Existing `GET /v1/transactions` and transaction detail routes remain available. Send filters in the query string, with no JSON request body. Repeat `account_id`, `status` and `transaction_type` for multiple values. Omit unused filters; do not send empty strings or the literal string `null` for numeric or enum filters. Amounts are integer cents, and dates are integer milliseconds since epoch. ```bash curl --get 'https://api.uat.koard.com/v2/transactions' \ -H 'X-Koard-apikey: YOUR_API_KEY' \ --data-urlencode 'status=captured' \ --data-urlencode 'status=refunded' \ --data-urlencode 'min_amount=100' \ --data-urlencode 'limit=50' \ --data-urlencode 'offset=0' ``` The response contains `transactions`, `total`, `limit`, `offset` and `page`. The default page size is 50, with a maximum of 200. The old POST default was 100, so set `limit=100` explicitly if that page size is required. Replace the old JSON `account_ids` array with repeated `account_id` query parameters. An account filter can identify a merchant or a parent whose descendants should be included, subject to the caller's access. Without an account filter, v2 searches the caller's hierarchy. It does not request every account in the environment. > **Passthrough-only transaction edits:** `PUT /v1/transactions/{transaction_id}` is reserved for [PSP](/docs/appendix/glossary#psp "Company that serves multiple merchants with payment facilitation.") [passthrough EMV](/docs/appendix/glossary#passthrough-emv "Koard forwards EMV data and later receives the final outcome from the PSP.") flows where Koard is only forwarding EMV data and later receiving the final transaction outcome from the PSP. It is not available for TSYS, Elavon, Fiserv, Worldpay, or other directly managed processor flows. Unsupported processor edits return `401`, while both missing and inaccessible transactions return `404`. # Authentication All Koard API requests must be authenticated using an [API key](/docs/appendix/glossary#api-key "Secret header credential for REST calls; v5 keys use scoped permissions.") passed in the `x-koard-apikey` header. ```bash curl https://api.koard.com/v1/accounts/YOUR_ACCOUNT_ID \ -H "x-koard-apikey: YOUR_API_KEY" \ -H "Accept: application/json" ``` ## Headers | Header | Required | Value | | ---------------- | ------------------- | ------------------ | | `x-koard-apikey` | Always | Your Koard API key | | `Accept` | Always | `application/json` | | `Content-Type` | When sending a body | `application/json` | The header name is case-insensitive — `x-koard-apikey` and `X-Koard-apikey` are equivalent. ## API Keys API keys are provisioned per account. A partner-level key can manage merchants and terminals under it. A merchant-level key (a key bound to a `merchant` account) is limited to that merchant's own operations. Retrieve or rotate your API key from the [Koard MMS](https://app.koard.com) under your account settings, or manage keys programmatically via the [API Keys endpoints](/api-reference/apikeys). Keep your API key secret. Never expose it in client-side code or public repositories. ### Scoped Permissions Each v5 key carries an explicit list of permissions. A permission is a string in the form `resource:action` (for example `payments:read`, `terminals:create`) or `resource:action:subtype` (for example `accounts:create:merchant`). A key can do **only** what it has been granted — there are no implicit grants. In particular, holding a write permission does **not** imply the matching read permission. Two macro permissions exist: `all` grants every concrete permission (for trusted server-side backends), and `legacy_all` is a grandfathered bucket set only on migrated pre-v5 keys (it cannot be granted to new keys). | Resource | Actions | | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | `payments` | `tap-ios`, `tap-android`, `read`, `refund`, `tipadjust`, `capture`, `incremental-auth`, `void`, `confirm` | | `batches` | `read`, `open`, `edit`, `close` | | `terminals` | `read`, `create`, `edit`, `delete` | | `locations` | `read`, `create`, `edit`, `delete` | | `accounts` | `read`, `create:partner`, `create:merchant`, `edit`, `delete` | | `billing` | `read` | | `credentials` | `read`, `create`, `edit`, `delete` | | `apikeys` | `read`, `create`, `edit`, `delete` (and the `:sub` variants below) | | `webhooks` | `read`, `create`, `edit`, `delete` (Koard [PSP](/docs/appendix/glossary#psp "Company that serves multiple merchants with payment processing.") only) | #### Own account vs. sub-accounts API-key management permissions are split by surface. The base form (`apikeys:read`, `apikeys:create`, `apikeys:edit`, `apikeys:delete`) governs keys on **your own** account. The `:sub` variants (`apikeys:read:sub`, `apikeys:create:sub`, `apikeys:edit:sub`, `apikeys:delete:sub`) govern keys on **descendant** (sub-) accounts. These are distinct grants — holding the self permission never satisfies a sub-account operation, and vice versa. #### Privilege-escalation guard When you create a key using an API key, you may only grant permissions that key holds. Requesting a permission outside your grant is rejected with `403`. The `webhooks:*` grants are available to PSP accounts. ### Visibility: 401 vs 404 When a key lacks a permission, the response distinguishes between "denied" and "invisible": * If your key holds **some** permission on the target resource type but not the specific operation, you get `401`. * If your key holds **zero** permissions on the target resource type (the resource is invisible to you), you get `404` — out-of-scope resources are hidden, never advertised. ### Key Lifecycle | Action | Effect | | --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | | **Create** (`POST`) | Issues the key and returns the plaintext once. Defaults to a long expiry; pass `expires_at` to set your own. | | **Revoke** (`PUT` with `status: "revoked"`) | Disables the key for authentication. **Recoverable** — reinstate with `PUT status: "active"`. | | **Reinstate** (`PUT` with `status: "active"`) | Re-enables a revoked key. Does not work on a deleted key. | | **Delete** (`DELETE`) | Permanent soft-delete. The key can never be reinstated. Idempotent — repeating the call returns the same deleted key. | | **Expire** (`expires_at` passes) | The key stops authenticating automatically. | Permissions are **immutable** after creation — a `PUT` may change `name`, `expires_at`, and `status` only. To change a key's permissions, create a new key and delete the old one. Revoking or deleting a key takes effect immediately: in-flight sessions authenticated with that key are locked out. Use `POST /v5/apikeys` to create a key on your own account, or supply `account_id` to target a descendant account with `apikeys:create:sub`. `expires_at` is optional; provide an ISO 8601 / RFC 3339 date-time. Omission or null on create applies the 100-year default expiry. Omission or null on update leaves the current expiry unchanged. Errors, including 400/401/403/404, use `{error, message, details}`. A 404 also hides an account or key outside your hierarchy. See the [API key management guide](/api-reference/api-key-management) for examples and the [API Keys reference](/api-reference/apikeys) for the full contract. # Response Codes Koard uses standard HTTP status codes. | Code | Meaning | | ----- | ---------------------------------------------------------------------------------------------------------------------------------------- | | `200` | Success | | `201` | Resource created | | `400` | Validation failure — check the response body for details | | `401` | Missing or invalid [API key](/docs/appendix/glossary#api-key "Secret header credential for REST calls; v5 keys use scoped permissions.") | | `403` | Insufficient permissions for this operation | | `404` | Resource not found | | `409` | Conflict — resource already exists or state mismatch | | `423` | Locked — merchant account is blocked and cannot perform this operation | | `429` | Rate limited — slow down and retry | | `500` | Unexpected server error | ## Error Body Format Most errors return a structured envelope with a machine-readable `error` code, a short `message` category, and a human-readable `details` string. Branch on `error` in client code; `details` is always a string (never a list or object): ```json { "error": "validation_error", "message": "Request validation failed", "details": "query: sort_by: Input should be 'name' or 'volume'" } ``` `500` responses never leak internal exception messages or stack traces — the incident is logged server-side and `details` carries only a generic string. ### Envelope Error Codes When an error is returned as the structured envelope, the `error` field is one of: | `error` | HTTP | `message` | | ------------------------- | ---- | ------------------------- | | `validation_error` | 400 | Request validation failed | | `authentication_required` | 401 | Authentication required | | `permission_denied` | 403 | Permission denied | | `not_found` | 404 | Resource not found | | `conflict` | 409 | Resource state conflict | | `rate_limited` | 429 | Rate limit exceeded | | `upstream_error` | 502 | Upstream processor error | | `internal_error` | 500 | Internal server error | ## Batch Close Responses `POST /v1/batches/{batch_id}/close` returns the resulting **Batch object** when the processor reports a duplicate (HTTP 409) or rejects settlement (HTTP 502), as well as on acceptance (HTTP 200). Inspect the batch's `response` and `rejection_reason`. Those responses do not use the error envelope above. See [Running Batches](/docs/batch-and-settlements/running-batches). A timeout or server error during a payment operation does not establish that the processor declined it. Use [outcome recovery](/docs/payments/idempotency) before submitting again. # API Key Management Manage scoped API keys with `/v5/apikeys`. Send your existing key in the `X-Koard-apikey` header. Use the API URL and key for the same environment: `https://api.uat.koard.com` for UAT or `https://api.koard.com` for production. The [API Keys reference](/api-reference/apikeys) lists request fields and current permissions. [Authentication](/api-reference/authentication) explains how permissions control access. ## Create a key ```sh curl -X POST 'https://api.uat.koard.com/v5/apikeys' \ -H 'X-Koard-apikey: YOUR_EXISTING_API_KEY' \ -H 'Content-Type: application/json' \ -d '{"name":"Server integration","permissions":["terminals:read"],"expires_at":"2030-01-01T00:00:00Z"}' ``` `name` and a non-empty `permissions` array are required. Use permission values such as `terminals:read`, rather than a `scopes` field. Unknown fields and invalid permissions are rejected. You can grant only permissions your existing key holds. Permission grants also depend on your account type. Omit `account_id`, or send null, to create the key on your own account. To create it for a descendant account, supply that account's ID and use a key with `apikeys:create:sub`. Creating a key on your own account requires `apikeys:create`. `expires_at` is optional. Provide an ISO 8601 / RFC 3339 date-time, such as `2030-01-01T00:00:00Z`. Omission or null applies an expiry 100 years from creation. The HTTP 201 response contains the plaintext in `key`, along with its metadata: ```json { "id": "YOUR_NEW_KEY_ID", "account_id": "YOUR_ACCOUNT_ID", "name": "Server integration", "key": "GENERATED_KEY_RETURNED_ONCE", "key_last4": "abcd", "permissions": ["terminals:read"], "status": "active", "expires_at": "2030-01-01T00:00:00Z", "created_at": "2026-10-10T17:00:00Z", "last_used_at": null, "is_legacy": false, "always_retrievable": false } ``` This response uses placeholder identifiers and key text. Save the actual generated key securely when you receive it. Subsequent reads return `key: null`; use `key_last4` to identify it. Existing legacy keys and supplied keys created with `always_retrievable: true` can return plaintext on reads. `always_retrievable` requires a caller-supplied `key` in the valid v5 format for the environment. ## List or read keys ```http GET /v5/apikeys GET /v5/apikeys?account_id=YOUR_MERCHANT_ACCOUNT_ID GET /v5/apikeys/YOUR_KEY_ID ``` Listing returns an array; reading one key returns its metadata object. `apikeys:read` grants access to keys on your own account. `apikeys:read:sub` grants access to descendant accounts. Omitting the listing's `account_id` filter returns the accounts allowed by those permissions; supplying it narrows the result to a visible account. ## Update, revoke or reinstate Use `PUT /v5/apikeys/{key_id}`. You can change only `name`, `expires_at`, and `status`; permissions are immutable. Omitted or null values leave the corresponding field unchanged. ```json { "name": "Renamed integration", "expires_at": "2030-01-01T00:00:00Z" } ``` To temporarily disable the key: ```json { "status": "revoked" } ``` To reinstate a revoked key: ```json { "status": "active" } ``` Reinstating a key does not extend its expiry. An expired key still cannot authenticate. These operations require `apikeys:edit` for your own account or `apikeys:edit:sub` for a descendant. A successful update returns HTTP 200 and the updated metadata. ## Delete or rotate `DELETE /v5/apikeys/{key_id}` permanently deletes the key. It returns HTTP 200 with key metadata and `status: "revoked"`. Repeating DELETE returns the same deleted key. A deleted key cannot be reinstated. Deletion requires `apikeys:delete` for your own account or `apikeys:delete:sub` for a descendant. To rotate, create a replacement key, update your integration, then delete the old key. Use a different authorized key when deleting one that currently authenticates your requests. ## Errors API-key errors use the same response structure: ```json { "error": "validation_error", "message": "Request validation failed", "details": "permissions: at least one permission is required" } ``` The `details` text varies with the failure. Branch on `error` and HTTP status. | HTTP status | Meaning | |---|---| | 400 | Invalid request, including empty permissions, an unknown field, invalid status, or invalid supplied key. | | 401 | Authentication failed, or your key sees API-key resources but lacks the requested operation permission. | | 403 | You requested a permission you do not hold, or a permission grant that your account cannot issue. | | 404 | The key or target account does not exist, is deleted or outside your hierarchy, or your key has no API-key resource access. | `legacy_all` can appear on migrated keys. It cannot be requested when creating a new key; use explicit permissions or `all` where appropriate. # Getting Started with Koard Koard supports contactless acceptance on iPhone and Android and facilitates payments to the merchant's configured processor. Choose your [iOS setup](/docs/setting-up-the-ios-sdk/installing-the-sdk) or [Android setup](/docs/setting-up-the-android-sdk/installing-the-sdk), then configure the merchant and active location. Use these guides to integrate contactless acceptance into your iOS or Android application. The Apple-specific setup below applies to Tap to Pay on iPhone; Android requirements are in the [Android SDK installation guide](/docs/setting-up-the-android-sdk/installing-the-sdk). [](https://www.koard.com/videos/apple-tap-to-pay.mp4) ## What is Koard? Koard facilitates contactless payments to the merchant's configured processor through its iOS and Android SDKs. Payment Service Providers (PSPs) and Independent Software Vendors (ISVs) can integrate acceptance into their existing applications. Koard supports SDK integration, certification coordination, and merchant onboarding. Launch timing depends on platform approval, processor configuration, and your certification scope. ## Key Benefits * **SDK Integration**: Released iOS and Android SDKs for supported devices * **Certification Support**: Use approved platform and processor configurations and complete the approvals required for your app * **Comprehensive Support**: End-to-end guidance from Apple setup to merchant onboarding * **Precompiled SDK**: Easy integration with our .xcframework distribution * **Merchant Management**: Complete portal for merchant configuration and credential management * **Payment Routing**: Flexible integration with multiple payment processors ## Who Can Use Koard? Koard is designed for: * **Payment Service Providers (PSPs)** serving multiple merchants * **Independent Software Vendors (ISVs)** building payment applications * **Large merchants** processing significant transaction volumes * **US and Europe-based enterprises** with existing POS infrastructure **Prerequisites** **Business Requirements** * US or Europe-based enterprise PSP or ISV * Serve multiple merchants or process large transaction volumes * Existing experience with physical POS terminals or similar technology * Ability to track items and orders in your own database * Capacity to update your app for Apple compliance requirements **Technical Requirements** * **iPhone Model**: iPhone XS or later * **iOS Version**: iOS 17.4 or later * **Apple Developer Account**: Organization-level account required * **GitHub Account**: Required for SDK dependency management * **Supported PSP**: Integration with a Koard-supported Payment Service Provider * **[Sandbox Apple Account](/docs/appendix/glossary#sandbox-apple-account "Dedicated Apple tester account on a test iPhone for certification and QA.")**: Dedicated Sandbox tester signed in on a test iPhone that will be used for certification and QA **Test Device Required**: Plan for a dedicated test iPhone running iOS 17.4 or later with your Sandbox Apple Account signed in. Production Apple IDs cannot be used for Sandbox testing. ## Integration Phases ### Phase 1: Apple Partnership Setup * Establish relationship with Apple through Koard * Confirm the platform and processor certification requirements for your scope * Set up [Apple Business Register](/docs/appendix/glossary#apple-business-register "Apple hub for PSP Tap to Pay documentation, registration, and certification.") Account * Configure environment and [KEK exchange](/docs/appendix/glossary#kek-exchange "Key-encryption-key setup used in Apple partnership / environment configuration.") processes ### Phase 2: Merchant Onboarding * Create merchant configurations via Koard's portal * Set up [merchant credentials](/docs/appendix/glossary#merchant-credentials "Credentials used to authenticate the Koard SDK; scoped to locations and terminals.") and processor integrations * Upload [VAR sheets](/docs/appendix/glossary#var-sheet "Processor-provided credential packet required to board a terminal.") for supported processors and gateways * Configure payment routing with chosen processors ### Phase 3: SDK Integration * Integrate Koard's precompiled .xcframework * Implement payment flows in your iOS application * Test transactions in certification environment * Prepare for production deployment ### Phase 4: Launch and Scale * Deploy to production environment * Onboard merchants and provision iPhone terminals * Monitor transactions and optimize performance * Scale your tap-to-pay business ## Quick Start Path 1. **Verify Prerequisites**: Confirm you meet all business and technical requirements 2. **Contact Koard**: Reach out to our team to discuss your integration needs 3. **Set Up Apple Partnership**: Work with Koard to establish your Apple relationship 4. **Configure Merchants**: Use our portal to set up your merchant configurations 5. **Integrate SDK**: Follow our technical integration guide 6. **Test and Launch**: Complete testing and go live with tap-to-pay payments ## Active Location in App Settings Your app must let operators select an active location in settings. That location determines the linked terminal and payment routing for the authenticated merchant. Validate the selection after login or a merchant change and show it before taking payments. See [Active Location and Terminal Routing](/docs/getting-started-with-koard/setting-up-the-merchant#active-location-and-terminal-routing). ## Next Steps * [Setting up the Merchant](/docs/getting-started-with-koard/setting-up-the-merchant) - Learn how to configure merchant accounts and credentials * [Creating a Sandbox Apple Account](/docs/setting-up-the-ios-sdk/creating-a-sandbox-apple-account) - Configure dedicated testers and devices * [Installing the SDK](/docs/setting-up-the-ios-sdk/installing-the-sdk) - Technical integration guide * [Payment Lifecycle](/docs/payments/payment-lifecycle) - Understand the complete payment flow * [Getting Ready for Production](/docs/setting-up-the-ios-sdk/getting-ready-for-production) - Prepare schemes and [API keys](/docs/appendix/glossary#api-key "Secret header credential for REST calls; v5 keys use scoped permissions.") for launch ## Support For questions or assistance with your integration: * **Technical Support**: Contact our development team * **Business Inquiries**: Reach out to Behailu at * **PSP Partnerships**: Ask about our updated list of supported Payment Service Providers # Setting up the Merchant Learn how to set up merchant accounts in the Koard [Merchant Management System (MMS)](/docs/appendix/glossary#mms "Koard portal for merchants, terminals, locations, credentials, and API keys.") and configure them for tap-to-pay payments. [![link icon](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLXNxdWFyZS1jb2RlLWljb24gbHVjaWRlLXNxdWFyZS1jb2RlIj48cGF0aCBkPSJtMTAgOS0zIDMgMyAzIi8+PHBhdGggZD0ibTE0IDE1IDMtMy0zLTMiLz48cmVjdCB4PSIzIiB5PSIzIiB3aWR0aD0iMTgiIGhlaWdodD0iMTgiIHJ4PSIyIi8+PC9zdmc+) Access UAT MMS ](https://app.uat.koard.com)[ Access Production MMS](https://app.koard.com) For the safety and security of your merchant accounts, complete our [merchant checklist](#verification-and-testing) before going live. Immediately after you create a merchant account, you can use it in testing environments. In a _sandbox_ (A sandbox is an isolated test environment that allows you to test Koard functionality without affecting your live integration. Use sandboxes to safely experiment with new features and changes), simulate transactions and use all of Koard's features without moving any money. To accept real payments, you must activate your merchant account to use live mode. **Prerequisites** Before you begin, ensure you have: * **Access to the Koard Merchant Management System** * **Valid business registration documents** * **Tax identification number** for each merchant * **[Merchant Category Code (MCC)](/docs/appendix/glossary#mcc "Four-digit ISO business-type code required at merchant onboarding.")** for each business * **Processor credentials** ([MID](/docs/appendix/glossary#mid "Processor-assigned merchant identifier used for routing and batching."), [TID](/docs/appendix/glossary#tid "Processor-assigned per-terminal identifier; batches are typically unique per MID/TID."), [VIN](/docs/appendix/glossary#vin "Vendor identifier from some processors, entered with MID/TID at terminal setup.")) or [Partner ID](/docs/appendix/glossary#partner-id "Alternate boarding identifier used by processors like Payroc instead of MID/TID/VIN.") * **[Sandbox Apple Account](/docs/appendix/glossary#sandbox-apple-account "Dedicated Apple tester account on a test iPhone for certification and QA.") on dedicated test iPhone** for validating [Tap to Pay](/docs/appendix/glossary#tap-to-pay "Apple contactless acceptance on iPhone without extra hardware.") flows **Dedicated Test Hardware**: Make sure your Sandbox Apple Account is signed in on a separate test iPhone. You'll need it to validate Tap to Pay flows before onboarding merchants in production. ## Access the Merchant Management System ### UAT Environment For testing and development, use the [UAT](/docs/appendix/glossary#uat "Isolated sandbox environment — transactions do not move live money.") MMS: **** ### Production Environment For live merchant onboarding: **** ## Create a Merchant To create a merchant, fill out the merchant application requesting basic information about the business, processor details, and location information. After creating the merchant, you can immediately start configuring terminals and processing payments. Koard's merchant onboarding requirements ensure compliance with payment processor regulations and Apple's tap-to-pay guidelines. These requirements come from our financial partners and Apple, and are intended to prevent abuse of the payment system. We review the information you provide internally to make sure that it complies with our merchant agreement. After you create a merchant account, you can't change its country. If you need to use Koard in a different country that we support, you must create a new merchant account. Privacy and security are priorities for Koard. Our merchant data handling follows industry standards for payment processing and Apple's security requirements. **1. Navigate to Accounts** 1. Log into the MMS 2. Go to the **Accounts** tab 3. You'll see a list of existing Partners 4. Click [![link icon](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Ik01IDEyaDE0Ii8+PHBhdGggZD0iTTEyIDV2MTQiLz48L3N2Zz4=) Add Account](#) ![getting-started-1](/getting-started-1.png) **2. Fill Required Fields** Every merchant requires these mandatory fields: * **Name**: Business name or legal entity name * **Tax ID**: Unique tax identification number (merchants with the same Tax ID will be grouped together) * **MCC Code**: 4-digit Merchant Category Code * **HQ Country**: Headquarters country location ![getting-started-2](/getting-started-2.png) **3. Save Merchant** Click **Save** to create the merchant account. ## Assign a Terminal **1. Create New Terminal** 1. After creating the merchant, click [![link icon](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxjaXJjbGUgY3g9IjEyIiBjeT0iMTIiIHI9IjEwIi8+PHBhdGggZD0iTTggMTJoOCIvPjxwYXRoIGQ9Ik0xMiA4djgiLz48L3N2Zz4=) New Terminal](#) 2\. You'll see the terminal configuration view ![getting-started-3](/getting-started-3.png) **2. Configure Terminal Details** For most processors, enter: * **MID**: Merchant ID from your processor * **TID**: Terminal ID from your processor * **VIN**: Vendor ID from your processor For processors like Payroc where merchants have their own ID: * Replace MID, TID, and VIN with the **Partner ID** (or Processing Terminal ID) 1. Configure the terminal details by clicking [![link icon](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwYXRoIGQ9Ik0xMSA0SDRhMiAyIDAgMCAwLTIgMnYxNGEyIDIgMCAwIDAgMiAyaDE0YTIgMiAwIDAgMCAyLTJ2LTciLz48cGF0aCBkPSJNMTguNSAyLjVhMi4xMjEgMi4xMjEgMCAwIDEgMyAzTDEyIDE1bC00IDEtMS00IDkuNS05LjV6Ii8+PC9zdmc+) Edit Terminal](#) ![getting-started-4](/getting-started-4.png) **3. Assign Location** 1. Go back to the merchant account to access the **Locations** tab ![locations-1](/locations-1.png) 2. Click [![link icon](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNiIgaGVpZ2h0PSIxNiIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxjaXJjbGUgY3g9IjEyIiBjeT0iMTIiIHI9IjEwIi8+PHBhdGggZD0iTTggMTJoOCIvPjxwYXRoIGQ9Ik0xMiA4djgiLz48L3N2Zz4=) New Location](#) 3. Assign the terminal to that location 4. This gives the merchant a physical location to accept payments from ![getting-started-5](/getting-started-5.png) ## Create Merchant Credentials Your merchants will use these credentials to authenticate with the Koard SDK and process payments. The credentials are tied to specific locations and terminals, ensuring secure payment processing. **1. Generate Credentials** 1. Navigate to the merchant's credential section 2. Click **Create [Merchant Credentials](/docs/appendix/glossary#merchant-credentials "Credentials used to authenticate the Koard SDK; scoped to locations and terminals.")** 3. Generate a unique **Code** and **PIN** for the merchant ![getting-started-6](/getting-started-6.png) **2. SDK Integration** Merchants can use these credentials to log into the SDK. This Swift example targets iOS SDK 1.0.21; follow [iOS Running Payments](/docs/setting-up-the-ios-sdk/running-payments#authenticate-the-merchant) or [Android authentication](/docs/setting-up-the-android-sdk/installing-the-sdk#step-7-authenticate-merchant) for the full platform flow: ```swift import KoardSDK // After SDK initialization, call from an async function or Task. try await KoardMerchantSDK.shared.login( code: "YOUR_CODE", pin: "YOUR_PIN" ) ``` **3. Location Configuration** * Merchants can set their location from the SDK or your mPOS app * Location is required to create a card reader session on the iPhone * This ensures payments are processed at the correct merchant location **Security Note**: Keep merchant credentials confidential. Store them securely on your servers and never share them in client-side code or public repositories. ## Multiple Processor Support Each merchant can be configured with multiple MIDs based on the supported processors you have access to: * **Primary Processor**: Main payment processor for the merchant * **Secondary Processors**: Backup or specialized processors * **Regional Processors**: Location-specific payment processing ## Verification and Testing **1. Verify Configuration** 1. Check that all required fields are completed 2. Verify processor credentials are correct 3. Ensure location is properly assigned **2. Test Integration** 1. Use the UAT environment to test merchant login 2. Verify terminal assignment works correctly 3. Test payment processing with test cards **3. Go Live** 1. Move merchant to production environment 2. Update SDK credentials for production 3. Monitor initial transactions ## Keep your merchant accounts safe After you set up your merchant accounts, you'll want to keep them secure. Here are our recommendations: * **Keep private information private**: Don't share merchant credentials and keep your secret [API keys](/docs/appendix/glossary#api-key "Secret header credential for REST calls; v5 keys use scoped permissions.") confidential on your own servers. As a reminder, Koard employees will never ask you for your keys. * **Use unique credentials**: Generate unique codes and PINs for each merchant. If you reuse credentials across merchants and one account is compromised, an attacker could access multiple merchant accounts. * **Use team members to provide others with access**: You can invite others (with limited access) to your Koard MMS account so that they can log in and take certain actions without full administrative access. * **Update your computer and browser regularly**: We recommend configuring your computer to automatically download and install updates. This helps protect your system against automated attacks and malware. * **Beware of phishing**: All genuine Koard sites use the `koard.com` domain and HTTPS. If you get an email from us that you don't expect, go directly to our site to log in. Don't enter your password after clicking a link in an email. * **Enable two-factor verification**: When you enable two-factor authentication, you'll need to provide an additional unique code from your mobile device to complete the login process. This means that even if someone steals your username and password, they won't be able to log in. ## Best Practices * **Unique Tax IDs**: Ensure each merchant has a unique tax identification * **Proper MCC Codes**: Use accurate Merchant Category Codes for compliance * **Location Accuracy**: Verify physical locations match processor records * **Credential Security**: Store merchant credentials securely * **Regular Audits**: Periodically review merchant configurations ## Troubleshooting ### Common Issues * **Duplicate Tax IDs**: Merchants with same Tax ID will be grouped together * **Invalid MCC Codes**: Ensure 4-digit codes are valid for your region * **Processor Mismatch**: Verify processor credentials match your configuration * **Location Errors**: Ensure locations are properly assigned to terminals ### Support For technical issues with merchant setup: * Check the [Resources](/docs/appendix/resources) section * Contact Koard support for processor-specific issues * Review [Apple Best Practices](/docs/appendix/apple-best-practices-and-guidelines) for compliance ## See also * [iOS SDK Installation](/docs/setting-up-the-ios-sdk/installing-the-sdk) - Install and integrate the Koard Merchant SDK into your iOS application * [Tap to Pay Configuration](/docs/setting-up-the-ios-sdk/adding-support-for-tap-to-pay-on-iphone) - Configure Tap to Pay on iPhone functionality in your app * [Webhook Setup](/docs/webhooks/setting-up-webhooks) - Configure webhooks to receive real-time payment event notifications * [Payment Testing](/docs/setting-up-the-ios-sdk/running-payments) - Learn how to process and test payments with the Koard SDK * [Apple Best Practices](/docs/appendix/apple-best-practices-and-guidelines) - Follow Apple's guidelines and best practices for Tap to Pay development ## Active Location and Terminal Routing **Your app must provide a way to select the active location in settings.** Authenticate the merchant, load that merchant's available locations, and let the operator select the location where the device is taking payments. The selected `location_id` resolves the linked `terminal_id`, whose processor configuration, MID, and TID determine payment routing for that merchant. Show the active location in settings and on the payment screen. Select it before reader preparation or any payment. If the operator changes merchants, clear the previous selection and reload the new merchant's locations. If you remember a choice between sessions, validate that it still belongs to the authenticated merchant and still has a usable linked terminal. Do not silently reuse another merchant's location or select an arbitrary terminal. * **iOS:** call `KoardMerchantSDK.shared.setActiveLocationID(location.id)`, then follow the account-linking and reader preparation flow. * **Android:** call the suspending `sdk.setActiveLocation(location.id)`, then `sdk.enrollDevice()` and the reader preparation flow. Block payment initiation until location selection and required readiness checks have completed. If a location's terminal changes, reload the configuration and complete the SDK's readiness flow before taking another payment. # Setting up the Merchant via API Create a merchant account, board its terminal, associate a location, and issue merchant SDK login credentials. Use a backend API key with the permissions and account hierarchy access required by each endpoint. Use `https://api.uat.koard.com` and UAT processor credentials for testing. Production uses `https://api.koard.com` with separate keys and assigned merchant identifiers. Example values are placeholders, not certification credentials. ## 1. Select an Available Processor Configuration ```bash curl 'https://api.uat.koard.com/v1/processor-configs' \ -H 'X-Koard-apikey: YOUR_API_KEY' ``` Choose the configuration intended for the merchant and environment. Match its boarding format to the [processor guide](/docs/boarding-a-merchant/processor-configurations). A configuration ID for one processor cannot be combined with another processor's VAR sheet. ## 2. Create the Merchant Account Use the **merchant** request variant. `tax_id` and `mcc` are required for merchants and must be omitted when creating a partner. See [Creating Merchant and Partner Accounts](/docs/getting-started-with-koard/creating-accounts) for the differences and parent-account rules. ```bash curl -X POST 'https://api.uat.koard.com/v2/accounts' \ -H 'X-Koard-apikey: YOUR_API_KEY' \ -H 'Content-Type: application/json' \ -d '{ "type": "merchant", "name": "Example Coffee", "description": "Retail coffee shop", "address": { "street_line1": "123 Main Street", "city": "New York", "state": "NY", "zip": "10016", "country": "USA" }, "tax_id": "12-3456789", "mcc": "5812", "available_processor_configs": ["YOUR_TSYS_PROCESSOR_CONFIG_ID"] }' ``` The response is **201 Created**. Store the returned account `id` as `YOUR_MERCHANT_ACCOUNT_ID`. Merchant creation requires `type`, `name`, `tax_id`, `mcc`, and a US address with `city`, `state`, and `zip`. `description` is optional. Processor access must come from configurations your account can assign. Account creation does **not** automatically issue a REST API key. If the merchant needs one, create it separately through [API Key Management](/api-reference/api-key-management) with the required permissions. An API key and merchant SDK login credentials are different credentials. ## 3. Board the Terminal This is a TSYS example. Replace the processor configuration ID and all VAR-sheet identifiers with assigned values; retain leading zeros. Other processors use their own request format. ```bash curl -X POST 'https://api.uat.koard.com/v2/terminals' \ -H 'X-Koard-apikey: YOUR_API_KEY' \ -H 'Content-Type: application/json' \ -d ' { "account_id": "YOUR_MERCHANT_ACCOUNT_ID", "processor_config_id": "YOUR_TSYS_PROCESSOR_CONFIG_ID", "name": "Front Counter POS", "var_sheet": { "acquirer_bin": "123456", "merchant_number": "123456789012", "store_number": "0001", "terminal_number": "0002", "merchant_category_code": "5812", "merchant_name": "Example Coffee", "merchant_location": "New York", "merchant_state": "NY", "city_code": "10016", "acceptor_street_address": "123 Main Street", "industry_code": "R", "acceptor_phone": "2125550100", "acceptor_customer_service_phone": "2125550101", "currency_code": "840", "country_code": "USA", "language_indicator": "00", "time_zone_diff": "705" } } ' ``` The response is **201 Created** and contains the boarded terminal. Store its `terminal_id`. Use `name`, not `terminal_name`, in the request. A boarded terminal is distinct from an Apple reader terminal profile. ## 4. Create or Update the Location ```bash curl -X POST 'https://api.uat.koard.com/v1/locations' \ -H 'X-Koard-apikey: YOUR_API_KEY' \ -H 'Content-Type: application/json' \ -d '{ "account_id": "YOUR_MERCHANT_ACCOUNT_ID", "name": "Main Store", "terminal_id": "YOUR_RETURNED_TERMINAL_ID", "processor_config_id": "YOUR_TSYS_PROCESSOR_CONFIG_ID", "address": { "street_line1": "123 Main Street", "city": "New York", "state": "NY", "zip": "10016", "country": "USA" } }' ``` Store the returned location `id`. For an existing location, use `PUT /v1/locations/{location_id}` and send only changed fields, such as `terminal_id`. Keep the location and terminal on the same merchant account and use the intended processor configuration. ## 5. Issue Merchant SDK Credentials ```bash curl -X POST 'https://api.uat.koard.com/v1/accounts/credentials' \ -H 'X-Koard-apikey: YOUR_API_KEY' \ -H 'Content-Type: application/json' \ -d '{"account_id": "YOUR_MERCHANT_ACCOUNT_ID"}' ``` The response is **201 Created**. It contains `code` and a plaintext `pin`. Deliver them through a trusted channel. The plaintext PIN is returned on creation only; subsequent credential reads return metadata and the code, not the PIN. - List active credentials: `GET /v1/accounts/{account_id}/credentials`. - Read one credential: `GET /v1/accounts/credentials/{credential_id}`. ## 6. Select the Active Location in Your App **Include a location selector in app settings.** After SDK login, load the authenticated merchant's locations and set the operator's selected `location_id`. That location's linked terminal controls which processor configuration, MID, and TID are used for payments. Do not pass a terminal ID in place of a location ID or reuse a selection from another merchant. See [Active Location and Terminal Routing](/docs/getting-started-with-koard/setting-up-the-merchant#active-location-and-terminal-routing), then complete the platform's linking, enrollment, and reader preparation steps before taking a test payment. # Creating Merchant and Partner Accounts Use `POST /v2/accounts` to create a **merchant** or **partner** under your account hierarchy. Your authenticated account must be a PSP or partner with access to the intended parent. A merchant account cannot create child accounts. Choose the matching request variant in the [Accounts API reference](/api-reference/accounts). Account creation returns **201 Created** and the new account's `id`. Issue a REST API key separately when needed through [API Key Management](/api-reference/api-key-management). | Field | Merchant creation | Partner creation | |-------|-------------------|------------------| | `type` | `merchant` | `partner` | | `name` | Required business name | Required partner name | | `description` | Optional | Optional | | `tax_id` | Required merchant tax ID | Omit | | `mcc` | Required four-digit merchant category code | Omit | | `address` | Required US business address with `city`, `state`, and `zip` | Optional | | `surcharge_rate`, `surcharge_basis`, `surcharge_confirmation_required` | Optional merchant defaults | Omit | | `tax_rate`, `tax_basis` | Optional merchant defaults | Omit | | `parent_id` | Optional accessible parent account | Optional accessible parent account | | `available_processor_configs` | Optional assigned configuration IDs | Optional assigned configuration IDs | When `parent_id` is omitted, the caller's account becomes the parent. An explicit parent must be your account or an accessible descendant and must be able to have children. Merchant accounts cannot be parents. Omitting `available_processor_configs` inherits the parent's configuration access. Supply assigned IDs from the same environment when narrowing access; do not use an empty list to mean “no processor access.” ## Create a Merchant Send this JSON body to `POST /v2/accounts` with your backend API key: ```json { "type": "merchant", "name": "Example Coffee", "tax_id": "12-3456789", "mcc": "5812", "address": { "street_line1": "123 Main Street", "city": "New York", "state": "NY", "zip": "10016", "country": "USA" } } ``` The merchant tax ID, MCC, and address requirements apply in addition to the shared request's `type` and `name`. A missing or empty tax ID, missing MCC, or incomplete merchant address is rejected. The country defaults to the US when omitted; boarding is currently US only. Merchant defaults configure this merchant's payment behavior and do not configure its parent. Continue with [Setting up the Merchant via API](/docs/getting-started-with-koard/setting-up-the-merchant-via-api) to board a terminal, associate a location, and issue SDK credentials. ## Create a Partner Send this JSON body to the same endpoint: ```json { "type": "partner", "name": "Example Software Partner", "description": "Manages descendant merchant accounts" } ``` A partner organizes descendant accounts. Omit `tax_id`, `mcc`, surcharge settings, and tax-default fields. These fields describe a merchant's payment configuration. Create each merchant as its own account under the partner and board terminals for that merchant. ## Retrieve Accounts Use `GET /v2/accounts` to list accounts beneath the authenticated account. Add `sort_type=hierarchy` to return descendants nested under `children`. The caller's credentials determine the account scope. Keep the account `id` returned during creation and use `GET /v2/accounts/{account_id}` to retrieve it later. This lookup checks access to the requested account and returns `404` for an inaccessible account. See the [Accounts API reference](/api-reference/accounts) for the supported list filters and response fields. # TSYS terminal boarding ## Processor configurations Boarding requires a `processor_config_id` available to your account in the same environment. [List available configurations](/docs/boarding-a-merchant/processor-configurations) before choosing an ID. | Configuration | UAT `processor_config_id` | Production `processor_config_id` | | ------------- | ------------------------------------------------------------------------------------------------ | -------------------------------------- | | TSYS (no PIN) | `dcc6d302-2406-4973-b555-d070e66e6adb` TSYS AWS (no PIN): `c614b77b-875f-403a-b7f6-5aaea09b340c` | `0571c931-c150-4520-ba18-5466ce52929b` | | TSYS with PIN | `1f402cde-38c2-43fa-a665-c7a0440f5051` | `a9ca2524-5bff-4778-8833-11ee3aad4d0d` | | Netevia | `8363f0d7-10b0-4b90-add1-5b9ed6b01e5d` | `fea3db59-3964-4f23-8ad7-a907e147f993` | For access to TSYS with PIN, contact . For production access to TSYS (no PIN) and Netevia, contact . Examples with listed IDs use UAT. For production, use the production API URL, API key and configuration ID. Use `POST /v2/terminals` with the merchant's `account_id`, the assigned `processor_config_id`, and a `var_sheet` containing the TSYS values below. `name` is an optional terminal display name; `terminal_name` is also accepted. **TSYS (no PIN) and TSYS with PIN are different configurations.** Use the corresponding `processor_config_id` from the table above; they have separate request examples below. TSYS with PIN access requires contacting . The same VAR-sheet fields apply to TSYS with PIN and Netevia configurations. ## Required VAR-sheet fields | Field | What to send | | ------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | | `acquirer_bin` | Six-digit acquirer BIN | | `merchant_number` | Twelve-digit merchant number | | `store_number` | Four-digit store number assigned in the [VAR sheet](/docs/appendix/glossary#var-sheet "Processor-provided credential packet required to board a terminal.") | | `terminal_number` | Four-digit terminal number assigned in the VAR sheet | | `merchant_category_code` | Four-digit merchant category code | | `merchant_name` | Merchant business name | | `merchant_location` | Merchant city | | `merchant_state` | Two-letter US state code | | `city_code` | Merchant ZIP code | | `acceptor_street_address` | Merchant street address | | `industry_code` | Industry code assigned for the merchant, such as `R` for retail | | `acceptor_phone` | Merchant phone number | Use the assigned store and terminal numbers, including leading zeros. `0001` is an example and should be used only when it is the assigned value. API phone inputs accept formatted US numbers, including a leading `+1`, and normalize to ten digits. For example, `+1 (212) 555-0100` becomes `2125550100`. ## Optional VAR-sheet fields | Field | Behavior | | --------------------------------- | ---------------------------------------------------------------------------------------------- | | `acceptor_customer_service_phone` | Defaults to `acceptor_phone`; uses the same phone normalization | | `country_code` | Defaults to `USA`; `US`, `840`, and null also become `USA` | | `currency_code` | Defaults to `840` for US dollars | | `language_indicator` | Defaults to `00` | | `time_zone_diff` | Three-digit TSYS time-zone value; defaults to `705`. Use the assigned value for your merchant. | | `authentication_code` | Terminal authentication code, when needed to obtain a new key | | `gen_key` | Previously issued terminal key. Send either this or `authentication_code`, not both. | Snake\_case and camelCase VAR-sheet names are accepted. `merchant_location` means city, and `city_code` means ZIP. See [shared address rules](/docs/boarding-a-merchant/contracts#addresses). ## Request examples Send these request bodies to `POST /v2/terminals` with your Koard API key in the `X-Koard-apikey` header. Processor configuration IDs are listed by environment above. Merchant account IDs, credentials and merchant details below are fictional placeholders; replace them with your merchant's values. Preserve leading zeros. ### TSYS (no PIN) UAT example. Use your assigned VAR-sheet values. Store and terminal numbers must retain their leading zeros; the values below are examples, not defaults. ```json { "account_id": "00000000-0000-4000-8000-000000000001", "processor_config_id": "dcc6d302-2406-4973-b555-d070e66e6adb", "name": "Example Coffee", "var_sheet": { "acquirer_bin": "123456", "merchant_number": "123456789012", "store_number": "0001", "terminal_number": "0002", "merchant_category_code": "5812", "merchant_name": "Example Coffee", "merchant_location": "New York", "merchant_state": "NY", "city_code": "10016", "acceptor_street_address": "123 Main Street", "industry_code": "R", "acceptor_phone": "2125550100", "acceptor_customer_service_phone": "2125550101", "currency_code": "840", "country_code": "USA", "language_indicator": "00", "time_zone_diff": "705" } } ``` ### TSYS with PIN UAT example. Use your assigned VAR-sheet values. Store and terminal numbers must retain their leading zeros; the values below are examples, not defaults. ```json { "account_id": "00000000-0000-4000-8000-000000000001", "processor_config_id": "1f402cde-38c2-43fa-a665-c7a0440f5051", "name": "Example Coffee", "var_sheet": { "acquirer_bin": "123456", "merchant_number": "123456789012", "store_number": "0001", "terminal_number": "0002", "merchant_category_code": "5812", "merchant_name": "Example Coffee", "merchant_location": "New York", "merchant_state": "NY", "city_code": "10016", "acceptor_street_address": "123 Main Street", "industry_code": "R", "acceptor_phone": "2125550100", "acceptor_customer_service_phone": "2125550101", "currency_code": "840", "country_code": "USA", "language_indicator": "00", "time_zone_diff": "705" } } ``` ### Netevia UAT example. Use your assigned VAR-sheet values. Store and terminal numbers must retain their leading zeros; the values below are examples, not defaults. ```json { "account_id": "00000000-0000-4000-8000-000000000001", "processor_config_id": "8363f0d7-10b0-4b90-add1-5b9ed6b01e5d", "name": "Example Coffee", "var_sheet": { "acquirer_bin": "123456", "merchant_number": "123456789012", "store_number": "0001", "terminal_number": "0002", "merchant_category_code": "5812", "merchant_name": "Example Coffee", "merchant_location": "New York", "merchant_state": "NY", "city_code": "10016", "acceptor_street_address": "123 Main Street", "industry_code": "R", "acceptor_phone": "2125550100", "acceptor_customer_service_phone": "2125550101", "currency_code": "840", "country_code": "USA", "language_indicator": "00", "time_zone_diff": "705" } } ``` ## Update a terminal Use `PUT /v2/terminals/{terminal_id}` with only the fields you want to change: ```json { "var_sheet": { "merchant_name": "Example Coffee Downtown", "acceptor_street_address": "456 Main Street" } } ``` Omitted address fields keep their current values. See [automated batch scheduling](/docs/batch-and-settlements/automated-batch-scheduling) to configure an automatic batch schedule. # Fiserv terminal boarding ## Processor configurations Boarding requires a `processor_config_id` available to your account in the same environment. [List available configurations](/docs/boarding-a-merchant/processor-configurations) before choosing an ID. | Configuration | UAT `processor_config_id` | Production `processor_config_id` | | ------------------------------------- | ------------------------------------------------------------------------------------- | -------------------------------------- | | Fiserv RapidConnect | `6fb2f3e1-cd5c-4206-8edc-ff878cff572c` | Not listed | | Fiserv Nashville Global | `18083156-5ce9-4147-bf7c-3f792d010dc7` Fiserv: `1266dd34-e37e-43d0-ad54-9211fbad4c82` | `212ef266-1f7d-4dd3-a089-ee608473877c` | | Nashville North with merchant address | Not listed | Not listed | For production access to Fiserv RapidConnect, contact . If an ID is not listed, contact for the appropriate configuration. Examples with listed IDs use UAT. For production, use the production API URL, API key and configuration ID. Examples without a listed UAT ID are request templates. Replace `YOUR_PROCESSOR_CONFIG_ID` with an available configuration ID before submitting a request. Select the Fiserv configuration assigned to your merchant from `GET /v1/processor-configs`. Create the terminal with `POST /v2/terminals` using `account_id`, `processor_config_id`, `mid`, `tid`, and `mcc`. Fiserv configurations can require different VAR-sheet fields. Use the request format assigned to your configuration and the identifiers from your merchant's [VAR sheet](/docs/appendix/glossary#var-sheet "Processor-provided credential packet required to board a terminal."). | Request format | VAR-sheet details | | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | RapidConnect | Industry, merchant address, and assigned Group ID and [Datawire](/docs/appendix/glossary#datawire "Transport/ID used for some Fiserv terminals when Datawire is enabled.") ID when applicable | | Nashville Classic | Industry, merchant address and assigned equipment | | Nashville North | Industry, merchant address, assigned equipment and a required twelve-digit `settlement_mid` | | Cardnet North | Industry, merchant address and assigned equipment; optional `settlement_mid` | | Omaha | Industry, merchant address and assigned equipment | | Nashville Global | Seven-digit `mid`, required twelve-digit `var_sheet.merchant_number`, business name in `name`, and merchant address | | Nashville North with merchant address | Seven-digit `mid`, required twelve-digit `var_sheet.merchant_number`, business name in `name`, and merchant address | Use the examples below for RapidConnect and address-based Nashville configurations. The [Nashville Classic](/docs/boarding-a-merchant/fiserv/fiserv-nashville), [Nashville North](/docs/boarding-a-merchant/fiserv/fiserv-nashville-north), [Cardnet North](/docs/boarding-a-merchant/fiserv/fiserv-cardnet-north), and [Omaha](/docs/boarding-a-merchant/fiserv/fiserv-omaha) pages include their own request examples. If your assigned configuration's request format is unclear, confirm it with Koard before submitting the request. ## Merchant address Supply `merchant_street_address`, `merchant_city`, `merchant_state`, and `merchant_postal_code` in `var_sheet`. `merchant_street_address2` is optional. Country defaults to `USA`. `US`, `840`, and null also become `USA`. See [shared address rules](/docs/boarding-a-merchant/contracts#addresses). ## Assigned identifiers Use the [MID](/docs/appendix/glossary#mid "Processor-assigned merchant identifier used for routing and batching."), [TID](/docs/appendix/glossary#tid "Processor-assigned per-terminal identifier; batches are typically unique per MID/TID."), Group ID and Datawire ID assigned together for the merchant. Where your configuration uses Datawire, supplying `did` enables it. Setting `datawire_enabled` to true without a `did` requests a new Datawire ID. For Nashville North's equipment-based request, supply the twelve-digit `settlement_mid` separately from the seven-digit `mid`. For Nashville Global and Nashville North with merchant address, both the seven-digit `mid` and twelve-digit `var_sheet.merchant_number` are required. Send them as separate strings; one cannot replace the other. Keep the assigned `tid` unchanged, including leading zeros. Missing or malformed identifiers are rejected before boarding. ## Request examples Send these request bodies to `POST /v2/terminals` with your Koard API key in the `X-Koard-apikey` header. Processor configuration IDs are listed by environment above. Merchant account IDs, credentials and merchant details below are fictional placeholders; replace them with your merchant's values. Preserve leading zeros. ### Fiserv RapidConnect UAT example. Use the merchant, terminal, group and Datawire identifiers assigned together for this configuration. ```json { "account_id": "00000000-0000-4000-8000-000000000001", "processor_config_id": "6fb2f3e1-cd5c-4206-8edc-ff878cff572c", "name": "Example Coffee", "mid": "YOUR_ASSIGNED_MID", "tid": "00000001", "mcc": "5812", "var_sheet": { "merchant_street_address": "123 Main Street", "merchant_street_address2": "Suite 200", "merchant_city": "New York", "merchant_state": "NY", "merchant_postal_code": "10016", "country_code": "USA", "industry": "retail_qsr_grocery", "group_id": "40001", "did": "YOUR_ASSIGNED_DID", "datawire_enabled": true } } ``` ### Nashville Global UAT example. Use this when your assigned configuration requires a business name and merchant address. Send both the seven-digit mid and twelve-digit var\_sheet.merchant\_number, plus name, tid, mcc and the address fields shown. ```json { "account_id": "00000000-0000-4000-8000-000000000001", "processor_config_id": "18083156-5ce9-4147-bf7c-3f792d010dc7", "name": "Example Coffee", "mid": "1234567", "tid": "7654321", "mcc": "5812", "var_sheet": { "merchant_street_address": "123 Main Street", "merchant_street_address2": "Suite 200", "merchant_city": "New York", "merchant_state": "NY", "merchant_postal_code": "10016", "country_code": "USA", "merchant_number": "000123456789" } } ``` ### Nashville North with merchant address Request template: replace YOUR\_PROCESSOR\_CONFIG\_ID with an available configuration ID before submitting. Use this when your assigned configuration requires a business name and merchant address. Send both the seven-digit mid and twelve-digit var\_sheet.merchant\_number, plus name, tid, mcc and the address fields shown. ```json { "account_id": "00000000-0000-4000-8000-000000000001", "processor_config_id": "YOUR_PROCESSOR_CONFIG_ID", "name": "Example Coffee", "mid": "1234567", "tid": "7654321", "mcc": "5812", "var_sheet": { "merchant_street_address": "123 Main Street", "merchant_street_address2": "Suite 200", "merchant_city": "New York", "merchant_state": "NY", "merchant_postal_code": "10016", "country_code": "USA", "merchant_number": "000123456789" } } ``` ## Updates Use `PUT /v2/terminals/{terminal_id}` with only the fields you want to change. Omitted address fields retain their existing values. ## Batches and settlement For the supported managed Fiserv integration, Koard tracks batches automatically. Manual batch open, close and edit are unavailable. Monitor `batch.submitted` and subsequent acceptance or rejection webhooks, and retrieve batch details and reports through the read APIs. See [Fiserv Automatic Batches and Settlement](/docs/batch-and-settlements/fiserv-settlement). # Fiserv Nashville Classic ## Processor configurations Boarding requires a `processor_config_id` available to your account in the same environment. [List available configurations](/docs/boarding-a-merchant/processor-configurations) before choosing an ID. | Configuration | UAT `processor_config_id` | Production `processor_config_id` | |---|---|---| | Fiserv Nashville Classic | Not listed | Not listed | If an ID is not listed, contact [support@koard.com](mailto:support@koard.com) for the appropriate configuration. Examples without a listed UAT ID are request templates. Replace `YOUR_PROCESSOR_CONFIG_ID` with an available configuration ID before submitting a request. Use this format for a Nashville Classic configuration assigned to your merchant. See the [Fiserv overview](/docs/boarding-a-merchant/fiserv/fiserv-overview) for common fields. ## Request fields | Field | What to send | |---|---| | `account_id` | Merchant's Koard account ID | | `processor_config_id` | Assigned Nashville Classic configuration ID | | `mid` | Seven digits from the VAR sheet | | `tid` | Seven digits from the VAR sheet | | `mcc` | Four-digit merchant category code | | `var_sheet.group_id` | Assigned Group ID | | `var_sheet.industry` | Assigned industry value | | `var_sheet.equipment` | `CreditCallHCRC` or `CreditCallHCECRC`, as assigned | This format does not use `settlement_mid`. Supply the merchant street address, city, state and ZIP in `var_sheet`. Country defaults to `USA`; `US`, `840` and null also become `USA`. ## Request examples Send these request bodies to `POST /v2/terminals` with your Koard API key in the `X-Koard-apikey` header. Processor configuration IDs are listed by environment above. Merchant account IDs, credentials and merchant details below are fictional placeholders; replace them with your merchant's values. Preserve leading zeros. ### Fiserv Nashville Classic Request template: replace YOUR_PROCESSOR_CONFIG_ID with an available configuration ID before submitting. Use the VAR-sheet format assigned to your processor configuration. Replace all sample identifiers with your assigned values. ```json { "account_id": "00000000-0000-4000-8000-000000000001", "processor_config_id": "YOUR_PROCESSOR_CONFIG_ID", "name": "Example Coffee", "mid": "1234567", "tid": "7654321", "mcc": "5812", "var_sheet": { "merchant_street_address": "123 Main Street", "merchant_street_address2": "Suite 200", "merchant_city": "New York", "merchant_state": "NY", "merchant_postal_code": "10016", "country_code": "USA", "industry": "retail_qsr_grocery", "group_id": "10001", "equipment": "CreditCallHCRC" } } ``` ## Updates Use `PUT /v2/terminals/{terminal_id}` and send only changed fields. Omitted address fields retain their existing values. # Fiserv Nashville North ## Processor configurations Boarding requires a `processor_config_id` available to your account in the same environment. [List available configurations](/docs/boarding-a-merchant/processor-configurations) before choosing an ID. | Configuration | UAT `processor_config_id` | Production `processor_config_id` | |---|---|---| | Fiserv Nashville North (equipment-based) | Not listed | Not listed | If an ID is not listed, contact [support@koard.com](mailto:support@koard.com) for the appropriate configuration. Examples without a listed UAT ID are request templates. Replace `YOUR_PROCESSOR_CONFIG_ID` with an available configuration ID before submitting a request. Use this format for a Nashville North configuration assigned to your merchant. See the [Fiserv overview](/docs/boarding-a-merchant/fiserv/fiserv-overview) for common fields. This page covers the equipment-based request format. For a configuration that requires the business name and merchant address without equipment fields, use the [address-based Nashville North example](/docs/boarding-a-merchant/fiserv/fiserv-overview#nashville-north-with-merchant-address). ## Request fields | Field | What to send | |---|---| | `account_id` | Merchant's Koard account ID | | `processor_config_id` | Assigned Nashville North configuration ID | | `mid` | Seven digits from the VAR sheet | | `tid` | Seven digits from the VAR sheet | | `mcc` | Four-digit merchant category code | | `var_sheet.group_id` | Assigned Group ID | | `var_sheet.industry` | Assigned industry value | | `var_sheet.equipment` | `CreditCallLTDGTWRC` or `CRDCallResellerRCSS`, as assigned | Supply the assigned twelve-digit `var_sheet.settlement_mid`. It is required and is separate from the seven-digit `mid`. Supply the merchant street address, city, state and ZIP in `var_sheet`. Country defaults to `USA`; `US`, `840` and null also become `USA`. ## Request examples Send these request bodies to `POST /v2/terminals` with your Koard API key in the `X-Koard-apikey` header. Processor configuration IDs are listed by environment above. Merchant account IDs, credentials and merchant details below are fictional placeholders; replace them with your merchant's values. Preserve leading zeros. ### Fiserv Nashville North Request template: replace YOUR_PROCESSOR_CONFIG_ID with an available configuration ID before submitting. Use the VAR-sheet format assigned to your processor configuration. Replace all sample identifiers with your assigned values. ```json { "account_id": "00000000-0000-4000-8000-000000000001", "processor_config_id": "YOUR_PROCESSOR_CONFIG_ID", "name": "Example Coffee", "mid": "1234567", "tid": "7654321", "mcc": "5812", "var_sheet": { "merchant_street_address": "123 Main Street", "merchant_street_address2": "Suite 200", "merchant_city": "New York", "merchant_state": "NY", "merchant_postal_code": "10016", "country_code": "USA", "industry": "retail_qsr_grocery", "group_id": "10001", "equipment": "CreditCallLTDGTWRC", "settlement_mid": "123456789012" } } ``` ## Updates Use `PUT /v2/terminals/{terminal_id}` and send only changed fields. Omitted address fields retain their existing values. # Fiserv Cardnet North ## Processor configurations Boarding requires a `processor_config_id` available to your account in the same environment. [List available configurations](/docs/boarding-a-merchant/processor-configurations) before choosing an ID. | Configuration | UAT `processor_config_id` | Production `processor_config_id` | | -------------------- | ------------------------- | -------------------------------- | | Fiserv Cardnet North | Not listed | Not listed | For production access to Fiserv Cardnet North, contact . If an ID is not listed, contact for the appropriate configuration. Examples without a listed UAT ID are request templates. Replace `YOUR_PROCESSOR_CONFIG_ID` with an available configuration ID before submitting a request. Use this format for a Cardnet North configuration assigned to your merchant. See the [Fiserv overview](/docs/boarding-a-merchant/fiserv/fiserv-overview) for common fields. ## Request fields | Field | What to send | | --------------------- | ---------------------------------------------------------- | | `account_id` | Merchant's Koard account ID | | `processor_config_id` | Assigned Cardnet North configuration ID | | `mid` | Twelve digits from the VAR sheet | | `tid` | Six alphanumeric characters from the VAR sheet | | `mcc` | Four-digit merchant category code | | `var_sheet.group_id` | Assigned Group ID | | `var_sheet.industry` | Assigned industry value | | `var_sheet.equipment` | `CreditCallLTDGTWRC` or `CRDCallResellerRCSS`, as assigned | `var_sheet.settlement_mid` is optional and defaults to `mid`. Supply it when a different settlement [MID](/docs/appendix/glossary#mid "Processor-assigned merchant identifier used for routing and batching.") is assigned. Supply the merchant street address, city, state and ZIP in `var_sheet`. Country defaults to `USA`; `US`, `840` and null also become `USA`. ## Request examples Send these request bodies to `POST /v2/terminals` with your Koard API key in the `X-Koard-apikey` header. Processor configuration IDs are listed by environment above. Merchant account IDs, credentials and merchant details below are fictional placeholders; replace them with your merchant's values. Preserve leading zeros. ### Fiserv Cardnet North Request template: replace YOUR\_PROCESSOR\_CONFIG\_ID with an available configuration ID before submitting. Use the VAR-sheet format assigned to your processor configuration. Replace all sample identifiers with your assigned values. ```json { "account_id": "00000000-0000-4000-8000-000000000001", "processor_config_id": "YOUR_PROCESSOR_CONFIG_ID", "name": "Example Coffee", "mid": "123456789012", "tid": "A1B2C3", "mcc": "5812", "var_sheet": { "merchant_street_address": "123 Main Street", "merchant_street_address2": "Suite 200", "merchant_city": "New York", "merchant_state": "NY", "merchant_postal_code": "10016", "country_code": "USA", "industry": "retail_qsr_grocery", "group_id": "30001", "equipment": "CRDCallResellerRCSS", "settlement_mid": "123456789012" } } ``` ## Updates Use `PUT /v2/terminals/{terminal_id}` and send only changed fields. Omitted address fields retain their existing values. # Fiserv Omaha ## Processor configurations Boarding requires a `processor_config_id` available to your account in the same environment. [List available configurations](/docs/boarding-a-merchant/processor-configurations) before choosing an ID. | Configuration | UAT `processor_config_id` | Production `processor_config_id` | |---|---|---| | Fiserv Omaha | Not listed | Not listed | For production access to Fiserv Omaha, contact [support@koard.com](mailto:support@koard.com). If an ID is not listed, contact [support@koard.com](mailto:support@koard.com) for the appropriate configuration. Examples without a listed UAT ID are request templates. Replace `YOUR_PROCESSOR_CONFIG_ID` with an available configuration ID before submitting a request. Use this format for an Omaha configuration assigned to your merchant. See the [Fiserv overview](/docs/boarding-a-merchant/fiserv/fiserv-overview) for common fields. ## Request fields | Field | What to send | |---|---| | `account_id` | Merchant's Koard account ID | | `processor_config_id` | Assigned Omaha configuration ID | | `mid` | Seven digits from the VAR sheet | | `tid` | Seven digits from the VAR sheet | | `mcc` | Four-digit merchant category code | | `var_sheet.group_id` | Assigned Group ID | | `var_sheet.industry` | Assigned industry value | | `var_sheet.equipment` | `CREDITCALLHCRC`, as assigned | This format does not use `settlement_mid`. Supply the merchant street address, city, state and ZIP in `var_sheet`. Country defaults to `USA`; `US`, `840` and null also become `USA`. ## Request examples Send these request bodies to `POST /v2/terminals` with your Koard API key in the `X-Koard-apikey` header. Processor configuration IDs are listed by environment above. Merchant account IDs, credentials and merchant details below are fictional placeholders; replace them with your merchant's values. Preserve leading zeros. ### Fiserv Omaha Request template: replace YOUR_PROCESSOR_CONFIG_ID with an available configuration ID before submitting. Use the VAR-sheet format assigned to your processor configuration. Replace all sample identifiers with your assigned values. ```json { "account_id": "00000000-0000-4000-8000-000000000001", "processor_config_id": "YOUR_PROCESSOR_CONFIG_ID", "name": "Example Coffee", "mid": "1234567", "tid": "7654321", "mcc": "5812", "var_sheet": { "merchant_street_address": "123 Main Street", "merchant_street_address2": "Suite 200", "merchant_city": "New York", "merchant_state": "NY", "merchant_postal_code": "10016", "country_code": "USA", "industry": "retail_qsr_grocery", "group_id": "40001", "equipment": "CREDITCALLHCRC" } } ``` ## Updates Use `PUT /v2/terminals/{terminal_id}` and send only changed fields. Omitted address fields retain their existing values. # Elavon terminal boarding ## Processor configurations Boarding requires a `processor_config_id` available to your account in the same environment. [List available configurations](/docs/boarding-a-merchant/processor-configurations) before choosing an ID. | Configuration | UAT `processor_config_id` | Production `processor_config_id` | | ------------- | -------------------------------------- | -------------------------------------- | | Elavon | `4e4e07cb-a017-41ec-b718-c7c072a5b4a2` | `33dfde06-4c35-4416-a6ee-28ba31d434da` | For production access to Elavon, contact . Examples with listed IDs use UAT. For production, use the production API URL, API key and configuration ID. Select the Elavon configuration assigned to your merchant from `GET /v1/processor-configs`. Create the terminal with `POST /v2/terminals`. | Field | What to send | | --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | | `account_id` | Merchant's Koard account ID | | `processor_config_id` | Elavon configuration ID | | `name` | Optional terminal display name | | `mcc` | Four-digit merchant category code | | `tid` | Full 22-digit terminal identifier, if supplying it directly | | `var_sheet.bank_number` | Six-digit bank number, when supplying the identifier in parts | | `var_sheet.terminal_number` | Assigned terminal number of 1–16 digits, when supplying the identifier in parts; shorter values are left-padded with zeros to 16 digits | | `var_sheet` | Required object; use `{}` when supplying the full `tid` without other details | Supply the full `tid`, or supply both bank and terminal numbers. Use the assigned terminal number from your [VAR sheet](/docs/appendix/glossary#var-sheet "Processor-provided credential packet required to board a terminal."), including leading zeros. A lane number alone does not replace the terminal number. `mid` is optional. ## Request examples Send these request bodies to `POST /v2/terminals` with your Koard API key in the `X-Koard-apikey` header. Processor configuration IDs are listed by environment above. Merchant account IDs, credentials and merchant details below are fictional placeholders; replace them with your merchant's values. Preserve leading zeros. ### Elavon UAT example. Supply the six-digit bank number and the assigned terminal number from your VAR sheet. Terminal numbers accept 1–16 digits; shorter values are left-padded with zeros to 16 digits. You can instead supply the full 22-digit tid with an empty var\_sheet object. ```json { "account_id": "00000000-0000-4000-8000-000000000001", "processor_config_id": "4e4e07cb-a017-41ec-b718-c7c072a5b4a2", "name": "Example Coffee", "mcc": "5812", "var_sheet": { "bank_number": "123456", "terminal_number": "0000000012345678", "country_code": "USA", "currency_code": "840" } } ``` ## Optional merchant details The VAR sheet accepts `merchant_name`, `merchant_street_address`, `merchant_street_address2`, `merchant_city`, `merchant_state`, and `merchant_postal_code`. Use `country_code: "USA"` and `currency_code: "840"` for US dollar processing. `US`, `840`, and `0840` are also accepted as US country values. An omitted or null country defaults to `USA` on create. `acceptor_phone` and `acceptor_customer_service_phone` accept formatted US numbers, including a leading `+1`, and normalize to ten digits. ## Updates Use `PUT /v2/terminals/{terminal_id}` with only the changed fields. An omitted country keeps its current value; a null country becomes `USA`. See [shared address rules](/docs/boarding-a-merchant/contracts#addresses). # Worldpay terminal boarding ## Processor configurations Boarding requires a `processor_config_id` available to your account in the same environment. [List available configurations](/docs/boarding-a-merchant/processor-configurations) before choosing an ID. | Configuration | UAT `processor_config_id` | Production `processor_config_id` | |---|---|---| | Worldpay | `02eb8a39-b992-423b-ba41-2a0e28958519` | Not listed | For production access to Worldpay, contact [support@koard.com](mailto:support@koard.com). If an ID is not listed, contact [support@koard.com](mailto:support@koard.com) for the appropriate configuration. Examples with listed IDs use UAT. For production, use the production API URL, API key and configuration ID. Select the Worldpay configuration assigned to your merchant from `GET /v1/processor-configs`. Create the terminal with `POST /v2/terminals`. | Field | What to send | |---|---| | `account_id` | Merchant's Koard account ID | | `processor_config_id` | Worldpay configuration ID | | `mid` | Twelve-digit merchant ID | | `tid` | Three-digit terminal ID | | `mcc` | Four-digit merchant category code | | `var_sheet.bank_id` | Four-digit bank ID; required | | `var_sheet.lane_number` | Optional lane number; defaults to `001` | | `name` | Optional terminal display name | Keep identifiers as strings and preserve leading zeros. ## Request examples Send these request bodies to `POST /v2/terminals` with your Koard API key in the `X-Koard-apikey` header. Processor configuration IDs are listed by environment above. Merchant account IDs, credentials and merchant details below are fictional placeholders; replace them with your merchant's values. Preserve leading zeros. ### Worldpay UAT example. Supply the twelve-digit mid, three-digit tid and four-digit bank_id assigned to your merchant. ```json { "account_id": "00000000-0000-4000-8000-000000000001", "processor_config_id": "02eb8a39-b992-423b-ba41-2a0e28958519", "name": "Example Coffee", "mid": "123456789012", "tid": "001", "mcc": "5812", "var_sheet": { "bank_id": "1234", "lane_number": "001", "country_code": "USA" } } ``` ## Address and updates Optional VAR-sheet fields include `merchant_name`, `merchant_street_address`, `merchant_street_address2`, `merchant_city`, `merchant_state`, `merchant_postal_code`, and `country_code`. Country defaults to `USA` on create. `US` and `840` are also accepted as US country values. Use `PUT /v2/terminals/{terminal_id}` with only the changed fields. Omit country to keep its current value, or send null to set it to `USA`. See [shared address rules](/docs/boarding-a-merchant/contracts#addresses) and [automated batch scheduling](/docs/batch-and-settlements/automated-batch-scheduling). # Payroc terminal boarding ## Processor configurations Boarding requires a `processor_config_id` available to your account in the same environment. [List available configurations](/docs/boarding-a-merchant/processor-configurations) before choosing an ID. | Configuration | UAT `processor_config_id` | Production `processor_config_id` | | ------------- | -------------------------------------- | -------------------------------------- | | Payroc | `5c522102-dcc5-45b8-a57c-95792ea1ae9c` | `e3cc6c01-5532-48f7-8cd3-639e3c3f10b0` | For production access to Payroc, contact . Examples with listed IDs use UAT. For production, use the production API URL, API key and configuration ID. Use the **Processing [MID](/docs/appendix/glossary#mid "Processor-assigned merchant identifier used for routing and batching.")** and **Processing Terminal ID** assigned by Payroc. Select your Payroc configuration from `GET /v1/processor-configs`. Create the terminal with `POST /v2/terminals`: | Field | What to send | | --------------------- | ------------------------------------------ | | `account_id` | Merchant's Koard account ID | | `processor_config_id` | Payroc configuration ID | | `name` | Optional terminal display name | | `description` | Optional description | | `mid` | Payroc Processing MID | | `tid` | Payroc Processing Terminal ID | | `mcc` | Optional four-digit merchant category code | Send `mid` and `tid` at the top level. Omit `var_sheet`; a non-empty VAR sheet is rejected. ## Request examples Send these request bodies to `POST /v2/terminals` with your Koard API key in the `X-Koard-apikey` header. Processor configuration IDs are listed by environment above. Merchant account IDs, credentials and merchant details below are fictional placeholders; replace them with your merchant's values. Preserve leading zeros. ### Payroc UAT example. Send the assigned merchant and terminal identifiers at the top level. No VAR sheet is needed. ```json { "account_id": "00000000-0000-4000-8000-000000000001", "processor_config_id": "5c522102-dcc5-45b8-a57c-95792ea1ae9c", "name": "Example Coffee", "mid": "1234567", "tid": "7654321", "mcc": "5812" } ``` ## Updates Use `PUT /v2/terminals/{terminal_id}` to update the terminal. # Creating a Sandbox Apple Account Use a dedicated test iPhone and a Sandbox Apple Account for supported Apple Pay and Tap to Pay sandbox testing. Merchant SDK login credentials and this Apple account serve different purposes. ## Create a Tester Create the sandbox tester in App Store Connect following [Apple's current instructions](https://developer.apple.com/help/app-store-connect/test-in-app-purchases/create-a-sandbox-apple-account/). Use a dedicated email and the region required by your test plan. Keep the account credentials secure. ## Sign In on the Test Device For Apple Pay sandbox testing, Apple instructs you to sign out of the device's Apple Account and sign in using the Sandbox Apple Account. Use a dedicated device so this does not disrupt a personal or production device. Follow [Apple Pay sandbox testing](https://developer.apple.com/apple-pay/sandbox-testing/) for wallet provisioning and supported test cards. The App Store or Developer settings used for **in-app purchase** sandbox testing are a separate workflow. Signing into a StoreKit sandbox account, resetting purchase history, or looking for purchase receipts does not configure a contactless acceptance test. ## Prepare the Koard Integration - Use the approved Tap to Pay entitlement, supported device, and SDK release. - Select Koard UAT and the processor configuration and test credentials assigned to your project. - Log in as the merchant and select the active location before linking and preparing the reader. - Use the processor/network test cards or sandbox wallet tokens required by the certification plan. Generic PAN/CVV numbers do not replace contactless reader data. ## Troubleshooting Check the device Apple account, entitlement, Developer Mode, provisioning profile, active merchant location, and SDK readiness. Confirm that wallet provisioning and acceptance are using compatible sandbox test credentials. Consult Apple or your certification contact for account restrictions rather than assuming StoreKit purchase reset procedures apply. ## Next Steps - [Install the SDK](installing-sdk.md). - [Run payments](running-payments.md). - [Prepare for production](getting-ready-for-production.md). # Setting Up the Entitlement for Tap to Pay on iPhone Learn how to request and configure the [Tap to Pay on iPhone](/docs/appendix/glossary#tap-to-pay "Apple contactless acceptance on iPhone without extra hardware.") entitlement from Apple, which is required to enable contactless payment processing in your iOS application. [Get started with Koard](/docs/introduction) ## Overview The Tap to Pay on iPhone entitlement is a managed capability that Apple provides to authorized [Payment Service Providers (PSPs)](/docs/appendix/glossary#psp "Company that serves multiple merchants with payment processing.") and their partners. This entitlement allows your app to use Apple's ProximityReader framework to accept contactless payments directly on iPhone. This guide covers the entitlement request process, configuration steps, and verification procedures based on [Apple's official documentation](https://developer.apple.com/documentation/proximityreader/setting-up-the-entitlement-for-tap-to-pay-on-iphone). **What you learn** In this guide, you'll learn: * How to request the [Tap to Pay entitlement](/docs/appendix/glossary#tap-to-pay-entitlement "Apple Managed Capability required before building Tap to Pay apps.") from Apple * How to configure the entitlement in your Apple Developer account * How to add the entitlement to your Xcode project * How to verify the entitlement is properly configured * How to handle entitlement requirements for development and distribution **Prerequisites** Before you begin, ensure you have: * **Apple Developer Account** (organization-level account required) * **Account Holder Access** (entitlement requests must be made by the account holder) * **PSP Partnership** (your organization must be an authorized Payment Service Provider or partner) * **Xcode 16.3 or later** (for project configuration) * **App ID Created** (your app identifier must be registered in Apple Developer) * **[Sandbox Apple Account](/docs/appendix/glossary#sandbox-apple-account "Dedicated Apple tester account on a test iPhone for certification and QA.") signed in on test device** (dedicated iPhone running Developer Mode for entitlement validation) **Keep a Test iPhone Ready**: Entitlement validation requires running builds on a physical test device signed in with your Sandbox Apple Account. Do not rely on production Apple IDs for these flows. ## Requesting the Entitlement The Tap to Pay on iPhone entitlement must be requested through Apple's developer portal. This process is handled by Apple and requires approval. **1. Access Apple Developer Portal** 1. **Log in to your Apple Developer account** as the account holder 2. **Navigate to Certificates, Identifiers & Profiles** 3. **Select your organization** if you have multiple accounts **Important**: Only the account holder can request the Tap to Pay entitlement. Team members or admins cannot submit this request. **2. Request Tap to Pay Entitlement** 1. **Go to Identifiers** section 2. **Select your App ID** (or create one if needed) 3. **Navigate to Additional Capabilities** 4. **Find "Tap to Pay on iPhone"** in the list of capabilities 5. **Click "Request"** or "Enable" to submit your request **3. Wait for Approval** Apple will review your request and typically respond within **one to two business days**. You'll receive an email notification when the entitlement is approved or if additional information is needed. **Processing Time**: The approval process typically takes 1-2 business days. You must start with the development certificate to access Apple's [CERT environment](/docs/appendix/glossary#apple-cert "Apple certification environment used when validating Tap to Pay flows."). **4. Verify Entitlement Status** Once approved: 1. **Return to Certificates, Identifiers & Profiles** 2. **Select your App ID** 3. **Check Additional Capabilities** 4. **Verify "Tap to Pay on iPhone"** appears under Managed Capabilities The entitlement will now be available for use in your provisioning profiles. ## Configure Your App ID After receiving approval, configure your App ID to include the entitlement: ### Enable the Capability 1. **Navigate to Certificates, Identifiers & Profiles > Identifiers** 2. **Select your App ID** 3. **Scroll to Additional Capabilities** 4. **Enable "Tap to Pay on iPhone"** 5. **Save your changes** The capability will now be available for your App ID and can be included in provisioning profiles. ## Add Entitlement to Your Xcode Project Once the entitlement is approved and configured in your Apple Developer account, add it to your Xcode project: **1. Create Entitlements File** 1. **Open your project in Xcode** 2. **Select your project** in the Project Navigator 3. **Choose File > New > File** 4. **Select Property List** under Resource 5. **Name the file** `[YourProjectName].entitlements` 6. **Add it to your app target** **2. Configure Build Settings** 1. **Select your project** in the Project Navigator 2. **Select your app target** 3. **Go to Build Settings tab** 4. **Search for "Code Signing Entitlements"** 5. **Set the path** to `[YourProjectName].entitlements` **3. Add Entitlement Key** 1. **Open the `.entitlements` file** in Xcode 2. **Add the following key-value pair**: ```xml com.apple.developer.proximity-reader.payment.acceptance ``` The file should look like this: ```xml com.apple.developer.proximity-reader.payment.acceptance ``` **4. Update Provisioning Profile** 1. **In Xcode, go to Signing & Capabilities** 2. **Select your development team** 3. **Xcode will automatically download** a new provisioning profile that includes the entitlement 4. **Verify the entitlement** appears in the Capabilities section **Note**: If Xcode doesn't automatically update the provisioning profile, you may need to manually regenerate it in the Apple Developer portal. ## Verify Entitlement Configuration After configuring the entitlement, verify it's properly set up: ### Check in Xcode 1. **Select your project** in Xcode 2. **Select your app target** 3. **Go to Signing & Capabilities tab** 4. **Verify "Tap to Pay on iPhone"** appears in the Capabilities section ### Verify in Code You can programmatically verify the entitlement is present by checking the `readerIdentifier` property: ```swift import ProximityReader do { let readerIdentifier = try await PaymentCardReader().readerIdentifier print("Entitlement verified: (readerIdentifier)") // Entitlement is present and valid } catch { print("Entitlement error: (error)") // Handle notAllowed error if entitlement is missing } ``` If the entitlement is missing, `readerIdentifier` will throw a `notAllowed` error. ## Development vs Distribution Entitlements The Tap to Pay entitlement has different requirements for development and distribution: ### Development Entitlement * **Purpose**: Internal testing and development * **Provisioning**: Development provisioning profiles * **Distribution**: Ad-hoc distribution via .ipa files * **Testing**: Limited to registered test devices ### Distribution Entitlement * **Purpose**: TestFlight and App Store distribution * **Provisioning**: Distribution provisioning profiles * **Distribution**: TestFlight beta testing and App Store submissions * **Testing**: Available to all TestFlight testers and App Store users **Important**: If you've already received the development entitlement and need to distribute via TestFlight or the App Store, you must request the distribution entitlement separately. Respond to the original approval email from Apple to request the distribution entitlement. ## Troubleshooting ### Entitlement Not Appearing If the entitlement doesn't appear in your Apple Developer account: * **Verify account holder status**: Only the account holder can request entitlements * **Check PSP partnership**: Ensure your organization is authorized as a PSP or partner * **Contact Apple**: Reach out to Apple Developer Support if the entitlement is not available ### Entitlement Not Working in Xcode If the entitlement doesn't work in Xcode: * **Verify provisioning profile**: Ensure your provisioning profile includes the entitlement * **Check entitlements file**: Verify the `.entitlements` file contains the correct key * **Update provisioning profile**: Regenerate your provisioning profile in Apple Developer portal * **Clean build folder**: In Xcode, go to Product > Clean Build Folder ### Reader Identifier Returns Error If `readerIdentifier` throws a `notAllowed` error: * **Verify entitlement in Apple Developer**: Check that the entitlement is approved and enabled * **Check provisioning profile**: Ensure the profile includes the Tap to Pay capability * **Verify device registration**: For development, ensure test devices are registered * **Check code signing**: Verify your app is signed with the correct provisioning profile ## Additional Resources * [Apple's Tap to Pay Documentation](https://developer.apple.com/documentation/proximityreader/setting-up-the-entitlement-for-tap-to-pay-on-iphone) * [ProximityReader Framework Reference](https://developer.apple.com/documentation/proximityreader) * [Apple Developer Portal](https://developer.apple.com/account) ## See also * [Adding Support for Tap to Pay on iPhone](/docs/setting-up-the-ios-sdk/adding-support-for-tap-to-pay-on-iphone) - Complete implementation guide * [Installing the SDK](/docs/setting-up-the-ios-sdk/installing-the-sdk) - Set up the Koard iOS SDK * [Creating a Sandbox Apple Account](/docs/setting-up-the-ios-sdk/creating-a-sandbox-apple-account) - Prepare dedicated testers * [Getting Ready for Production](/docs/setting-up-the-ios-sdk/getting-ready-for-production) - Configure schemes and [API keys](/docs/appendix/glossary#api-key "Secret header credential for REST calls; v5 keys use scoped permissions.") * [Developing with Apple](/docs/appendix/developing-with-apple) - Security and environment guidelines # Adding Support for Tap to Pay on iPhone Enable [Tap to Pay on iPhone](/docs/appendix/glossary#tap-to-pay "Apple contactless acceptance on iPhone without extra hardware.") to allow merchants to accept contactless payments directly on their iPhone without additional hardware. If you're ready to start developing, see our [iOS SDK installation guide](/docs/setting-up-the-ios-sdk/installing-the-sdk). [Get started with Koard](/docs/introduction) **What you learn** In this guide, you'll learn: * How to request the [Tap to Pay entitlement](/docs/appendix/glossary#tap-to-pay-entitlement "Apple Managed Capability required before building Tap to Pay apps.") from Apple * How to configure your Xcode project for Tap to Pay * How to set up testing in developer mode * How to distribute your app for testing and production **Prerequisites** Before you begin, ensure you have: * **iOS 17.4 or later** (minimum required version) * **iPhone XS or later** (supported hardware) * **Apple Developer Account** (organization-level account required) * **Koard iOS SDK** (installed in your project) * **Valid merchant account** (configured in [Koard MMS](/docs/appendix/glossary#mms "Koard portal for merchants, terminals, locations, credentials, and API keys.")) * **[Sandbox Apple Account](/docs/appendix/glossary#sandbox-apple-account "Dedicated Apple tester account on a test iPhone for certification and QA.") signed in on test device** (dedicated iPhone in Developer Mode) **Use a Dedicated Test iPhone**: Keep your Sandbox Apple Account signed in on a separate test device. Production Apple IDs cannot complete Sandbox Tap to Pay transactions. ## Enable the Tap to Pay Entitlement Enabling the Tap to Pay Entitlement is a critical step that is handled by Apple. Typically, you will need to request the entitlement from Apple through their developer portal. ### Requesting Tap to Pay Entitlement from Apple To enable Tap to Pay on iPhone, follow these steps: 1. **Log in to your Apple Developer account** as the account holder 2. **Navigate to Certificates, Identifiers & Profiles** 3. **Select Tap to Pay on iPhone Entitlement** and submit a request 4. **Wait for approval** - Apple will add the entitlement under Managed Capabilities **Processing Time**: The process to get approval from Apple typically takes **one or two business days**. You will need to start with the development certificate to get access to the [Apple CERT environment](/docs/appendix/glossary#apple-cert "Apple certification environment used when validating Tap to Pay flows."). ## Configure Your Xcode Project Once you have the entitlement, configure your Xcode project: ### 1. Enable Tap to Pay Capability 1. **Sign in to your Apple Developer Account** 2. **Create an App ID** (if you don't have one already) 3. **Go to Certificates, Identifiers & Profiles > Identifiers** 4. **Select your app and go to Additional Capabilities** 5. **Enable Tap to Pay on iPhone and save** ### 2. Create a Provisioning Profile 1. **Open Xcode and select your project** 2. **Navigate to Signing & Capabilities** 3. **Under Provisioning Profile, select Download Profile** 4. **Choose the new provisioning profile** ### 3. Add Entitlements File 1. **In Xcode, select your project in Project Navigator** 2. **Create a new Property List file** (File > New > File > Resource) 3. **Name the file `[ProjectName].entitlements`** 4. **Open Build Settings and locate Code Signing Entitlements** 5. **Set its value to the path of the .entitlements file** Open the `.entitlements` file and add the following key-value pair: ```xml com.apple.developer.proximity-reader.payment.acceptance ``` **Additional Resources**: More details on enabling the tap to pay entitlement can be found in [Apple's Developer Documentation](https://developer.apple.com/documentation/passkit/tap_to_pay_on_iphone). ## Testing in Developer Mode To test the iOS app in developer mode, follow these additional steps: ### Enable Developer Mode 1. **On the test iPhone, go to Settings > Privacy & Security** 2. **Enable Developer Mode** ### Use a Sandbox Apple Account The sandbox account must be: * **Freshly created in App Store Connect** * **Signed into iCloud** * **Linked to the test iPhone** **Important**: Existing accounts attempting to test an App with Tap to Pay in cert mode will be blocked from creating a card reader session due to Apple's security policies. ### Register a Test Device 1. **Retrieve the UDID of the test device** 2. **Add the UDID to Allowed Devices in the Apple Developer account** 3. **Update the Provisioning Profile to include the allowed devices** **Device Management**: Any new test device will need to have the UDID uploaded and a **NEW provisioning profile** will need to be added anytime new devices are added. The app will then need a new archive file that will be shared with the new device. ### Distribute via .ipa File 1. **Create an .ipa build file in Xcode** 2. **Share the build with your internal team for testing** **Testing Recommendation**: It is highly recommended that you have a **secondary iPhone or higher** to test transactions. Otherwise, developer mode means that engineers will have to sign into a test account onto their own devices to test the mPOS app with Tap to Pay. This is a hard limitation set by Apple and has no workaround at the moment. ### TestFlight and App Store Distribution **TestFlight beta testing** and **App Store submissions** require a separate entitlement that allows distribution. If you've already completed your testing with the non-distribution entitlement, respond to the original email and re-request the Tap to Pay on iPhone Entitlement. ## Testing Environment Running a transaction in the cert environment will route payments to all the processor and card brands test environment. Transactions sent through this setup will **NOT authorize a real transaction** but will be production-like in terms of workflow. ### Test Cards Use the contactless test cards or sandbox wallet tokens assigned by your processor or network for your environment and certification plan. A test tap must produce actual reader-generated EMV data; a keyed PAN, expiry, and CVV do not simulate a COTS contactless transaction. Confirm the supported brands, wallet tokens, PIN/CVM scenarios, and expected outcomes with your certification contact. Follow the [test-card guidance](/docs/appendix/resources#test-cards); do not substitute another processor's fixtures. ## Production Deployment Before going live: 1. **Complete merchant verification** in Koard MMS 2. **Switch to production environment** in your app configuration 3. **Test with real payment methods** (in a controlled environment) 4. **Submit for App Store review** with the distribution entitlement 5. **Review scheme setup** in [Getting Ready for Production](/docs/setting-up-the-ios-sdk/getting-ready-for-production) to ensure the correct [API keys](/docs/appendix/glossary#api-key "Secret header credential for REST calls; v5 keys use scoped permissions.") ship with your archive. ## Card Reader Lifecycle in the SDK Once the entitlement and provisioning are in place, the SDK manages the Apple ProximityReader card reader. After authenticating the merchant, link the account and prepare the reader before taking payments. ```swift import KoardSDK // Call after merchant login and active-location selection. try await KoardMerchantSDK.shared.prepare() // On accountNotLinked, show a linking action. That action should run: // try await KoardMerchantSDK.shared.linkAccountAsync() // try await KoardMerchantSDK.shared.prepare() ``` ### Monitor Reader Status The SDK owns the reader event subscription. Use `KoardMerchantSDK.shared.status` for readiness and its `info` message; do not attach another `readerEvents` iterator. See the cancelable SwiftUI polling example in [Running Payments](/docs/setting-up-the-ios-sdk/running-payments#session-management). ```swift let status = KoardMerchantSDK.shared.status let paymentEnabled = status.isReady let statusMessage = status.info ``` ### Present the Tap to Pay Tutorial On iOS 18 and later, you can present Apple's built-in "How to Tap" tutorial from a visible view controller: ```swift if #available(iOS 18.0, *) { try KoardMerchantSDK.shared.presentTutorial(from: viewController) } ``` **Reader operations are serialized**: The SDK serializes ProximityReader operations (prepare, linkAccount, isAccountLinked, and reads), so a sale started while `prepare()` is still running waits for readiness instead of failing with a "reader busy" error. If you switch the active location, the reader re-prepares for the new location before the next charge. **Handle cancellation**: When the customer cancels at the Apple Tap to Pay sheet, the sale, pre-auth, and card-present refund flows throw `KoardMerchantSDKError.TTPPaymentFailed(.canceled)`. Treat this as a benign "canceled" outcome rather than a failure. ## Troubleshooting ### Common Issues * **Entitlement Not Found**: Ensure you've requested and received approval from Apple * **Provisioning Profile Issues**: Make sure your provisioning profile includes the Tap to Pay capability * **Device Registration**: Verify test devices are properly registered in your Apple Developer account * **Sandbox Account Issues**: Use a fresh Apple ID created specifically for testing ### Support For technical issues with Tap to Pay integration: * Check the [Apple Best Practices](/docs/appendix/apple-best-practices-and-guidelines) guide * Review [Payment Configurations](/docs/appendix/payment-configurations) for advanced settings * Contact Koard support for SDK-specific issues ## See also This wraps up the Tap to Pay setup. See the links below for next steps in your integration: * [Creating a Sandbox Apple Account](/docs/setting-up-the-ios-sdk/creating-a-sandbox-apple-account) - Set up test identities * [Installing the SDK](/docs/setting-up-the-ios-sdk/installing-the-sdk) - Complete SDK integration guide * [Running Payments](/docs/setting-up-the-ios-sdk/running-payments) - Implement payment processing * [Payment Lifecycle](/docs/payments/payment-lifecycle) - Understand the complete payment flow * [Getting Ready for Production](/docs/setting-up-the-ios-sdk/getting-ready-for-production) - Configure schemes for launch * [Apple Best Practices](/docs/appendix/apple-best-practices-and-guidelines) - Follow Apple's guidelines # Installing the SDK Install the Koard Merchant SDK to enable tap-to-pay functionality in your iOS application. If you're ready to start developing, see our [Tap to Pay configuration guide](/docs/setting-up-the-ios-sdk/adding-support-for-tap-to-pay-on-iphone). [Get started with Koard](/docs/introduction) **What you learn** In this guide, you'll learn: * How to add the Koard SDK with Swift Package Manager * How to install the SDK manually as an XCFramework * How to configure embed settings for proper functionality * How to verify your installation is working correctly **Prerequisites** Before you begin, ensure you have: * **Xcode 16.3 or later** (required for building and distribution) * **iOS 17.4+ deployment target** (minimum supported version) * **Swift 5.9+** (minimum Swift toolchain) * **iPhone XS or greater** (supported hardware for tap-to-pay) * **Valid Koard merchant account** (configured in [Koard MMS](/docs/appendix/glossary#mms "Koard portal for merchants, terminals, locations, credentials, and API keys.")) * **[Sandbox Apple Account](/docs/appendix/glossary#sandbox-apple-account "Dedicated Apple tester account on a test iPhone for certification and QA.") signed in on test device** (dedicated iPhone with Developer Mode enabled) **Current version**: The latest published release is **1.0.21**, adding optional transaction metadata, `deinit()`, and `isInitialized`. See [Transaction Metadata](/docs/setting-up-the-ios-sdk/transaction-metadata) and [SDK Lifecycle and Deinitialization](/docs/setting-up-the-ios-sdk/sdk-lifecycle). See the SDK [CHANGELOG](https://github.com/koardlabs/koard-ios/blob/main/CHANGELOG.md) for behavior changes — 1.0.20 changed how `prepare()` and `linkAccountAsync()` report failures, which affects existing integrations. **Test Device**: Use a dedicated test iPhone with your Sandbox Apple Account signed in. Avoid mixing production Apple IDs on the same hardware to prevent authentication conflicts. ## Step 1: Add the SDK We recommend **Swift Package Manager** for most projects. If you cannot use SPM, install the SDK manually as an XCFramework instead. ### Option A: Swift Package Manager (Recommended) 1. **In Xcode, choose File → Add Package Dependencies…** 2. **Enter the package URL**: `https://github.com/koardlabs/koard-ios.git` 3. **Set the dependency rule** to "Up to Next Major Version" starting from `1.0.21` 4. **Add the `KoardSDK` library product** to your app target You can also add it directly to a `Package.swift`: ```swift .package(url: "https://github.com/koardlabs/koard-ios.git", from: "1.0.21") ``` Then list `KoardSDK` as a dependency of your target. ### Option B: Manual XCFramework 1. **Download the latest `KoardSDK.xcframework.zip`** from the [Releases page](https://github.com/koardlabs/koard-ios/releases) or get it directly from the team 2. **Extract the ZIP file** to reveal the `KoardSDK.xcframework` bundle 3. **Drag `KoardSDK.xcframework` into your Xcode project** (or use your target's **General → Frameworks, Libraries, and Embedded Content → "+" → Add Other… → Add Files…**) 4. **Ensure "Copy items if needed" is checked** and the framework is added to your app target **Note**: The XCFramework format ensures compatibility across different architectures (iOS Simulator, iOS Device) and simplifies distribution compared to traditional frameworks. ### Option C: CocoaPods The SDK ships a podspec (`KoardSDK`), which vendors the same `KoardSDK.xcframework`. Add it to your `Podfile`: ```ruby pod 'KoardSDK', :git => 'https://github.com/koardlabs/koard-ios.git', :tag => '1.0.21' ``` Then run `pod install` and open the generated `.xcworkspace`. ## Step 2: Configure Linking The released KoardSDK 1.0.21 XCFramework contains a static binary. For a manual install, add it under your target's **Frameworks, Libraries, and Embedded Content** and select **Do Not Embed**. Xcode links the archive into your app; it must not be copied into the app as a dynamic framework. Swift Package Manager handles linking the binary target. Do not add a second manual copy when using the package. ## Step 3: Verify Installation ### Check Framework Integration 1. **Build your project** (⌘+B) to ensure there are no compilation errors 2. **Verify the package or framework appears** in your project navigator 3. **For a manual install, check the framework is listed** under "Frameworks, Libraries, and Embedded Content" ### Test Basic Import Add this import statement to one of your Swift files to verify the SDK is accessible: ```swift import KoardSDK ``` If the import succeeds without errors, your installation is working correctly. ## Step 4: Initialize the SDK Once the SDK is installed, initialize it early in your app lifecycle with your `KoardOptions` and [API key](/docs/appendix/glossary#api-key "Secret header credential for REST calls; v5 keys use scoped permissions."): ```swift import KoardSDK // Configure SDK options let options = KoardOptions( environment: .uat, // .uat | .production | .custom(String) loggingLevel: .debug // .none | .error | .warning | .debug | .verbose ) // Initialize with your API key KoardMerchantSDK.shared.initialize(options: options, apiKey: "your-koard-api-key") ``` For details on retrieving your key, see [Retrieving Your API Key](/docs/setting-up-the-ios-sdk/retrieving-your-api-key). For authentication and payments, see [Running Payments](/docs/setting-up-the-ios-sdk/running-payments). ## Troubleshooting ### Common Installation Issues * **Build Errors**: Ensure you're using Xcode 16.3 or later * **Runtime Crashes**: For a manual XCFramework install, verify the static framework is linked and set to "Do Not Embed" * **Import Errors**: Check that the package product (or XCFramework) was added to the correct target, and that you `import KoardSDK` * **Architecture Issues**: The XCFramework automatically handles different architectures ### Verification Checklist * [ ] Xcode 16.3 or later installed * [ ] iOS 17.4+ deployment target set * [ ] `KoardSDK` added via Swift Package Manager, or `KoardSDK.xcframework` added and set to "Do Not Embed" * [ ] Project builds without errors * [ ] `import KoardSDK` works correctly ## Next Steps Once the SDK is installed and configured: * [Create a Sandbox Apple Account](/docs/setting-up-the-ios-sdk/creating-a-sandbox-apple-account) - Ensure your testers are ready * [Configure Tap to Pay](/docs/setting-up-the-ios-sdk/adding-support-for-tap-to-pay-on-iphone) - Enable contactless payment functionality * [Implement Payments](/docs/setting-up-the-ios-sdk/running-payments) - Add payment processing to your app * [Understand Payment Lifecycle](/docs/payments/payment-lifecycle) - Learn about the complete payment flow * [Get Ready for Production](/docs/setting-up-the-ios-sdk/getting-ready-for-production) - Set up schemes and API keys for launch ## See also This wraps up the SDK installation. See the links below for next steps in your integration: * [Creating a Sandbox Apple Account](/docs/setting-up-the-ios-sdk/creating-a-sandbox-apple-account) - Set up dedicated testers * [Adding Tap to Pay](/docs/setting-up-the-ios-sdk/adding-support-for-tap-to-pay-on-iphone) - Enable contactless payments * [Running Payments](/docs/setting-up-the-ios-sdk/running-payments) - Implement payment processing * [Payment Lifecycle](/docs/payments/payment-lifecycle) - Complete payment flow guide * [Apple Best Practices](/docs/appendix/apple-best-practices-and-guidelines) - iOS development guidelines # Getting Ready for Production Prepare your Koard integration to move beyond the Sandbox by configuring Xcode schemes, build configurations, and [API keys](/docs/appendix/glossary#api-key "Secret header credential for REST calls; v5 keys use scoped permissions.") for both development and production. **What You Learn** In this guide, you'll learn how to: * Create separate build configurations and schemes in Xcode * Inject environment-specific Koard API keys and endpoints * Automate build-time switching between Sandbox and production settings * Validate your production readiness checklist before submitting to the App Store **Prerequisites** Before you begin, ensure you have: * **Koard API keys** for both Sandbox and production * **Info.plist or `.xcconfig` access** to store environment values * **Xcode project admin access** to edit schemes and build settings * **Dedicated test devices** with [Sandbox Apple Accounts](/docs/appendix/glossary#sandbox-apple-account "Dedicated Apple tester account on a test iPhone for certification and QA.") for final verification ## 1. Duplicate Build Configurations 1. In Xcode, select your project in the Project Navigator. 2. Under **PROJECT → Info**, duplicate your existing `Debug` and `Release` configurations. Name the copies `Debug-Prod` and `Release-Prod`. 3. Point the production build configurations to `.xcconfig` files (optional but recommended) such as `Koard-Dev.xcconfig` and `Koard-Prod.xcconfig`. ```text // Xcode .xcconfig: $() prevents // from starting a comment. Koard-Dev.xcconfig KOARD_API_BASE_URL = https:/$()/api.uat.koard.com KOARD_API_KEY = ${KoardSandboxAPIKey} Koard-Prod.xcconfig KOARD_API_BASE_URL = https:/$()/api.koard.com KOARD_API_KEY = ${KoardProductionAPIKey} ``` Keep credentials out of source control. Build settings and Info.plist values are readable from a shipped app, even when injected through CI. Only use the intended scoped SDK client key in the app; privileged REST API keys belong on your backend. ## 2. Customize Schemes for Each Environment Following Apple’s [Customizing the Build Schemes](https://developer.apple.com/documentation/xcode/customizing-the-build-schemes-for-a-project) guidance, create two top-level schemes: * `KoardApp-Dev` mapped to the `Debug`/`Release` configurations * `KoardApp-Prod` mapped to the `Debug-Prod`/`Release-Prod` configurations 1. Open **Product → Scheme → Manage Schemes**. 2. Duplicate your primary scheme and rename it `KoardApp-Prod`. 3. Assign the correct build configuration for each action (Build, Run, Archive, etc.). 4. Uncheck **Shared** while iterating, then re-enable sharing once the configuration is stable so teammates receive the new scheme in source control. **Tip**: Keep the production scheme archived with **Release-Prod** to ensure App Store submissions always use production endpoints and credentials. ## 3. Switch API Keys at Build Time Expose the Koard API key and environment to your app using Info.plist substitutions or Swift build flags. ### Option A: Info.plist placeholders 1. Add keys like `KOARD_API_BASE_URL` and `KOARD_API_KEY` to your Info.plist. 2. Reference them using `${KOARD_API_BASE_URL}` placeholders. 3. Resolve them in code at runtime: ```swift struct KoardAppConfig { static let baseURL: URL = { guard let urlString = Bundle.main.object(forInfoDictionaryKey: "KOARD_API_BASE_URL") as? String, let url = URL(string: urlString) else { fatalError("Missing or invalid KOARD_API_BASE_URL") } return url }() static let apiKey: String = { guard let key = Bundle.main.object(forInfoDictionaryKey: "KOARD_API_KEY") as? String else { fatalError("Missing KOARD_API_KEY") } return key }() } ``` ### Option B: Swift compilation conditions 1. Add custom flags in **Build Settings → Swift Compiler - Custom Flags** (e.g., `-DKOARD_ENV_SANDBOX` and `-DKOARD_ENV_PRODUCTION`). 2. Use those flags to branch logic: ```swift #if KOARD_ENV_PRODUCTION let environment: KoardEnvironment = .production let loggingLevel: KoardLoggingLevel = .error #else let environment: KoardEnvironment = .uat let loggingLevel: KoardLoggingLevel = .debug #endif let options = KoardOptions( environment: environment, loggingLevel: loggingLevel ) KoardMerchantSDK.shared.initialize(options: options, apiKey: apiKey) ``` Use this approach when you prefer compile-time enforcement of environment differences, such as disabling test-specific UI in production builds. **Environment and logging**: `KoardOptions.environment` accepts `.uat`, `.production`, or `.custom(String)`. Lower the `loggingLevel` for production builds (for example `.error` or `.none`) so debug logs don't ship to release users. ## 4. Verify Device and Account Setup * Install the **Dev** build on a dedicated test iPhone that remains signed in with your Sandbox Apple Account. * Install the **Prod** build on a separate device or reset the test device before signing in with the production Apple ID. * Run smoke tests for [Tap to Pay](/docs/appendix/glossary#tap-to-pay "Apple contactless acceptance on iPhone without extra hardware."), refunds, reversals, and settlement cutovers in each environment. **Deploy Deliberately**: Never archive or submit to App Store Connect using a Sandbox scheme. Require production code reviews to confirm the `KoardApp-Prod` scheme was used for the final archive. **Authentication and Keychain**: Login is session-token only — the SDK persists the session token and never stores the merchant code, PIN, or alias. `logout()` clears only the SDK's own Keychain entries (it no longer performs a blanket delete of the host app's Keychain items), so signing out will not disturb other credentials your app stores. ## 5. Pre-launch Checklist * [ ] Xcode schemes map to the correct build configurations. * [ ] Production API keys and endpoints live outside of source control. * [ ] Privileged backend API keys remain on your server; the app uses the intended scoped SDK client key. * [ ] Sandbox and production devices are authenticated with the appropriate Apple IDs. * [ ] A rollback plan is documented in case production rollout needs to be paused. ## Next Steps * [Creating a Sandbox Apple Account](/docs/setting-up-the-ios-sdk/creating-a-sandbox-apple-account) to keep development builds isolated. * [Running Payments](/docs/setting-up-the-ios-sdk/running-payments) to validate production behavior before launch. * Coordinate with your Koard partner manager to schedule final Apple certification checks. # Launching on the App Store Everything you need to do with Apple — separate from your Koard integration — before your app can go live to merchants. **What you'll learn** In this guide, you'll learn: * The 7-step launch journey and where most teams get stuck * How to pick your distribution path before you write a line of code * The two entitlements you need (and why TestFlight requires the second one) * In-app experience requirements Apple inspects during review * The `prepare()` call and why it must happen at launch, not at checkout * The three video walkthroughs Apple requires — and how to record them * What to include in your App Store Connect submission notes * The most common rejection reasons and how to avoid every one **This is separate from your Koard integration.** Even after your Koard integration is technically complete, you cannot launch your app to merchants without Apple's approval. That approval is a separate, multi-step process you run directly with Apple. Most customers who reach this stage without a plan lose 2–6 weeks to back-and-forth with Apple Review. Track your progress in **Launch Readiness** inside the [Koard MMS](/docs/appendix/glossary#mms "Koard portal for merchants, terminals, locations, credentials, and API keys.") — it mirrors this guide step by step. ## Why this matters Four of the most common rejection reasons we see from Apple Review: 1. Submission videos don't show how a merchant accepts Apple's Terms & Conditions or links the merchant account. 2. No in-app merchant education — the developer planned to train merchants 1:1 in person. Apple requires _in-app_ education on top of whatever else you do. 3. The `prepare()` call isn't implemented, which means the first tap-to-pay attempt in front of a real customer takes 30–40 seconds. 4. Button copy mixes "Tap to Pay" with "[Tap to Pay on iPhone](/docs/appendix/glossary#tap-to-pay "Apple contactless acceptance on iPhone without extra hardware.")" — Apple is strict about the full product name. None of that is a Koard problem. All of it is an Apple-side gate, and it routinely costs developers weeks they didn't plan to spend. This guide and the in-MMS checklist exist so you find these things in week one of design — not in week one of submission. ## The launch journey Each step is a distinct gate. You cannot skip any of them. **1. Request the Development Entitlement** Free and near-instant. Lets you build and test on developer-registered devices against the [PSP](/docs/appendix/glossary#psp "Company that serves multiple merchants with payment processing.") sandbox. [Request at Apple's developer portal](https://developer.apple.com/contact/request/tap-to-pay-on-iphone) **2. Build to spec** Use this guide and the Koard MMS checklist to build the required UX — awareness moment, merchant education, T\&C flow, and checkout experience. Don't skip any section. **3. Record the three walkthrough videos** Apple requires a New User, Existing User, and Checkout walkthrough. These cannot be screen-recorded — use a second device to film the screen. **4. Request the Publishing Entitlement** Reply to the email Apple sent you when they granted the Dev entitlement. Apple reviews your app against the v1.5.1 checklist. Allow approximately 5 business days. **TestFlight requires this entitlement.** You cannot distribute via TestFlight until Publishing is granted. There is no shortcut. **5. Submit to App Store Connect** Separate review by the App Store team on top of the entitlement review. Include your test account credentials, video links, wireframes, and entitlement declaration in the submission notes. **6. Pilot** Apple recommends testing with a small group of representative merchants before GA. Use TestFlight or ship behind a feature flag you control. **7. General Availability** Flip your backend flag or publish your App Store listing with Tap to Pay enabled. Update your product page messaging only after the flag is live. ## Pick your distribution path first Apple has different requirements depending on how your app reaches merchants. Decide before you design any screens — the distribution path determines which checklist items are required for you. | Path | Who it's for | Onboarding | Awareness in app | Merchant education | | --------------------- | ---------------------------------------------- | -------------------------------- | ----------------------------------------- | ------------------ | | **Public App Store** | SMBs, anyone discovering your app via search | Required to be in-app, < 15 min | Required (full-screen modal, push, email) | Required | | **Unlisted App** | Specific BYO-device audience reached via URL | External OK | Recommended | Highly recommended | | **Custom App** | One named enterprise customer, branded version | External OK | Recommended | Highly recommended | | **Enterprise (ADEP)** | MDM-deployed to managed devices | External (no Apple ID on device) | Optional | Highly recommended | **Public App Store triggers the full in-app onboarding requirement.** Customers who pick this path because "we want it discoverable" often don't realize it requires a complete in-app merchant sign-up flow that works in under 15 minutes. **Most underused:** Custom App and Unlisted App, which carry lighter requirements for enterprise deployments. Apps that ship via MDM to managed devices are typically a fit for Unlisted. If you're not sure, the Koard solutions team can talk it through — but pick before you start designing screens. ## Two entitlements, not one | Entitlement | What it lets you do | How to get it | When | | --------------- | ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- | ---------------------------------- | | **Development** | Build, run on developer-registered devices, test against the PSP sandbox | [Request at Apple's developer portal](https://developer.apple.com/contact/request/tap-to-pay-on-iphone) | Day one | | **Publishing** | TestFlight, App Store, internal enterprise distribution | Reply to the email Apple sent you with the Dev entitlement. Apple reviews your app against the v1.5.1 checklist. | Once your build meets requirements | **TestFlight requires the Publishing Entitlement.** If you planned to ship a small pilot via TestFlight first, that already requires you to pass Apple's full review. There is no shortcut. ## The in-app experience Apple requires Apple inspects four moments in your user flow. Each has its own checklist covered in the sections below. Together they form the bulk of the v1.5.1 review. **Digital onboarding (Public distribution only)** A new user who just downloaded your app must be able to apply to become an authorised merchant _inside the app_, on an iPhone, and — for the majority of approvable users — accept their first payment within 15 minutes. Practical interpretation: * An external "contact sales" form does not count as digital onboarding. * A `WKWebView` wrapper around your existing onboarding portal _does_ count. * If your KYC requires manual review, the path must still start in-app and use push/email/SMS to bring the user back. **Awareness & enrollment** At least one **awareness moment** — Apple's strong preference is a full-screen modal — must communicate to eligible users that Tap to Pay on iPhone exists in your app. New merchants see it during onboarding. Existing merchants see it on first login after the feature ships. Required elements: * A launch email (Apple has a template). * A push notification (Apple has a template). * A way to enroll _outside_ the checkout flow (in Settings or similar). * A way to enroll _from_ the checkout flow if the user taps "Tap to Pay" without being enrolled. * Only admin-class users can accept the T\&C. Non-admins must see a "contact your admin" message. **Don't forget existing users.** The awareness moment is required for both new merchants during onboarding and existing merchants on their first login after the feature ships. Apple rejects apps that only show it to new users. **Merchant education** This is the most frequently missed requirement. You must provide in-app education demonstrating how to accept payment with Tap to Pay on iPhone. _External training — 1:1 sessions, newsletters, training videos sent by email — does not satisfy this requirement on its own._ **The Koard SDK handles this for you.** The Koard SDK wraps Apple's [`ProximityReaderDiscovery`](https://developer.apple.com/documentation/ProximityReader/ProximityReaderDiscovery) API and surfaces Apple-designed education screens directly in your app. Wiring up the Koard SDK's merchant-education entry point satisfies Apple's checklist item 4.1\* in full — Apple's own language: _"If you use ProximityReaderDiscovery this will fulfill all of the merchant education requirements."_ No need to design your own screens. Two things you still need to handle yourself: 1. **Make education reachable later.** The post-enrollment moment is covered by the SDK, but Apple also requires that users can find the education screens from Settings or Help (checklist item 4.2). Add a "Tap to Pay on iPhone" row in Settings that re-invokes the SDK's education flow. 2. **Cover region-specific requirements if applicable.** If you deploy in a PIN-required region, your education must demonstrate PIN entry and its accessibility features (4.6). If you deploy in a Fallback-required region, demonstrate the fallback payment method (4.7). If you're targeting iOS earlier than 18, you must build your own education screens using Apple's Marketing Guide and Toolkit assets covering, at minimum: * How to accept a contactless card (landscape position, top of iPhone) * How to accept Apple Pay and other digital wallets * PIN entry + accessibility (region-dependent) * Fallback payment method (region-dependent) **Transaction experience** The "Tap to Pay on iPhone" button at checkout must: * Be in a prominent, no-scroll location. * Use exact copy: `"Tap to Pay on iPhone"` (or `"Tap to Pay"` only if the button is too small; `"Charge"` only if Tap to Pay is your sole acceptance method). * **Never** be greyed out or hidden based on enrollment status — if the user isn't enrolled, tapping starts enrollment. * Use the `wave.3.right.circle` or `wave.3.right.circle.fill` SF Symbol if using an icon. After a successful tap: show a processing screen, then a clear approved/declined/timed-out result, and a digital receipt option (SMS, email, QR, or iOS Share). **Button copy is strictly enforced.** Apple is literal — "Tap to Pay" and "Tap to Pay on iPhone" are not interchangeable. Use the full name unless space genuinely does not allow it. ## The `prepare()` call — don't skip this Call `KoardMerchantSDK.shared.prepare()` after initialization, merchant login, and active-location selection, before the customer needs to tap. Preparation time depends on the device, network, and merchant profile. Display inline progress and leave payment controls disabled until the reader is ready. On foreground return, inspect readiness and use your preparation coordinator to avoid overlapping tasks. Do not discard preparation errors with `try?`: an unlinked account needs an explicit terms-acceptance action using `linkAccountAsync()`, followed by preparation. Reader-token and other errors need their own visible recovery path. See [Running Payments](/docs/setting-up-the-ios-sdk/running-payments#prepare-a-card-reader-session) for the published SDK calls and [Apple Best Practices](/docs/appendix/apple-best-practices-and-guidelines#reader-management) for a preparation coordinator. ## Terms & Conditions: two paths There are exactly two ways a merchant can accept Apple's Tap to Pay T\&C. Pick one based on your distribution path. **User-led (default)** In-app, the merchant signs into their Apple Account, taps "Accept", and the device kicks off terminal-profile building. This is the path for Public, Unlisted, and Custom App distribution where merchants have their own Apple ID on the device. **Never cache the T\&C status locally.** Always read T\&C acceptance state from Apple via the Koard SDK. A local flag can fall out of sync with Apple's records and cause unexpected T\&C prompts at the worst possible moment. **Enterprise (Apple Business Connect)** For MDM-managed devices without an Apple ID, an organisation admin accepts terms on behalf of the merchant via **Apple Business Connect**. Koard provides the token your admin uses to do this — talk to your Koard solutions contact to set it up _before_ you deploy devices. If the merchant hasn't linked in ABC, the user will see the T\&C prompt on the device anyway. This is the path most MDM-deployed enterprise apps take. It's documented in Apple's v1.5.1 checklist item 3.8.2. ## The three required videos Apple requires three video walkthroughs as part of the Publishing Entitlement review. **You must record with a second device.** The Tap to Pay on iPhone reader screen cannot be screen-recorded. Use a separate iPhone to film the device under test. **New User Flow** Show each of the following in order — Apple rejects videos that skip or cut any step: 1. Account creation 2. KYC (if applicable) 3. Merchant approval 4. Tap to Pay awareness moment 5. T\&C acceptance 6. Merchant education 7. Terminal profile configuration progress indicator 8. Completed indicator 9. One full transaction **Existing User Flow** Show each of the following in order: 1. Sign in to existing account 2. Tap to Pay button visible before T\&C is accepted 3. Awareness moment for existing users 4. T\&C acceptance 5. Merchant education 6. Progress indicator 7. One full transaction 8. PIN entry (if applicable for your region) 9. Fallback payment method (if applicable for your region) **Checkout Flow** Show each of the following in order: 1. Add items to cart (or enter amount) 2. Payment options screen 3. Tap to Pay button 4. Initiate and complete a Tap to Pay transaction 5. PIN entry (if applicable) 6. Fallback (if applicable) **Common rejection:** the videos exist but skip a step ("we cut to after T\&C acceptance"). Re-record showing every step in sequence. If you need to unlink your Apple Account to re-record T\&C acceptance, Apple has documentation on resetting the T\&C state. ## Submitting to App Store Connect For Public, Unlisted, or Custom App distribution, passing the Publishing Entitlement review is not the end. Your app then goes through standard App Store Review _plus_ a Tap to Pay-specific review. In your App Store Connect submission notes, include: * A declaration that you are using the Tap to Pay on iPhone entitlement. * A description of your use case (e.g. "a point-of-sale app for SMB merchants"). * **A test account that works.** Apple says "a vast majority of rejections are due to test accounts not working properly." Validate on a fresh device before submitting. * A link to your video walkthrough _and_ high-fidelity wireframes of your checkout experience. * If your app is geo-fenced or uses a feature flag for Tap to Pay — declare that explicitly. * If Tap to Pay is your _only_ acceptance method, set `UIRequiredDeviceCapabilities` to include `iphone-ipad-minimum-performance-a12` so incompatible devices cannot download the app. **Enterprise apps: do not mention MDM.** If you're MDM-distributing in the enterprise, do not mention MDM in your App Store Connect app details. It flags a different (incorrect) entitlement review. Route enterprise distribution via ADEP. **Respond on the same thread.** Apple will often respond to a missing piece as a "rejection" with a note in the body saying it's actually a request for information. Reply on the same message thread — don't open a new one. ## Common pitfalls | # | Pitfall | Catch it before submission by… | | -- | ---------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | | 1 | Submission videos skip T\&C / merchant-linking | Using the Videos checklist in MMS Launch Readiness — it lists every Apple-required step per video | | 2 | No in-app merchant education ("we train in person") | Wiring up the Koard SDK's merchant-education entry point (wraps `ProximityReaderDiscovery`, iOS 18+) — satisfies 4.x in one call | | 3 | `prepare()` not implemented or called at checkout | Implementing on day one at app launch and `applicationWillEnterForeground` | | 4 | Test account credentials don't work on Apple's review device | Validating on a fresh iPhone _before_ submitting | | 5 | Tap to Pay button uses wrong copy or greys out when not enrolled | Apple is literal — use "Tap to Pay on iPhone"; never grey the button | | 6 | Mentioned MDM in App Store Connect notes for an enterprise app | Strip the MDM mention; route enterprise via ADEP, not the App Store | | 7 | Awareness moment present but only for new users | Add an existing-user awareness moment too (full-screen modal on first login post-launch) | | 8 | T\&C local cache out of sync with Apple | Always read T\&C status from Apple via the Koard SDK — never trust a local flag | | 9 | Geo-fencing not declared in App Store Connect notes | Add a line: "App is geo-fenced to US, CA. Test account works in these regions." | | 10 | Marketing channels skipped (launch email, push, hero banner) | Items 6.1, 6.2, 6.3 — required for Public distribution | ## After approval: pilot, then GA Apple strongly recommends piloting with a small group of representative merchants before flipping to general availability. Two options: * **TestFlight** — invite-only, requires the Publishing Entitlement. * **Feature flag** — ship the binary to GA with Tap to Pay hidden behind a backend flag you control. This is the path Apple recommends for existing apps, because most users will already have the updated version when you flip the flag. If you go this route, declare it in your App Store Connect submission, and do not update your App Store product page with Tap to Pay messaging until you flip the flag. Apple ships a pilot questionnaire in the Getting Started PDF (Appendix). Use it. ## Quick reference links * [Apple Tap to Pay on iPhone developer site](https://developer.apple.com/tap-to-pay-on-iphone/) * [Request the entitlement](https://developer.apple.com/contact/request/tap-to-pay-on-iphone/) * [Human Interface Guidelines — Tap to Pay on iPhone](https://developer.apple.com/design/human-interface-guidelines/tap-to-pay-on-iphone) * [Apple Marketing Guidelines](https://developer.apple.com/tap-to-pay/marketing-guidelines/) * [ProximityReaderDiscovery API (iOS 18+)](https://developer.apple.com/documentation/ProximityReader/ProximityReaderDiscovery) * [App Store Review Guidelines](https://developer.apple.com/app-store/review/guidelines/) ## Where to get help * **In-MMS checklist** — `Launch Readiness` in your Koard dashboard mirrors this guide section by section. * **Apple entitlement questions** — reply on your existing `applepayentitlements@apple.com` thread, citing your Case ID. * **PSP-side questions** (T\&C from Apple vs local, ABC tokens for enterprise, `prepare()` semantics) — your Koard solutions contact. ## See also * [Setting Up the Entitlement for Tap to Pay on iPhone](/docs/setting-up-the-ios-sdk/setting-up-the-entitlement-for-tap-to-pay-on-iphone) - Request and configure the Apple development entitlement * [Adding Support for Tap to Pay on iPhone](/docs/setting-up-the-ios-sdk/adding-support-for-tap-to-pay-on-iphone) - Configure Xcode and run your first test transaction * [Getting Ready for Production](/docs/setting-up-the-ios-sdk/getting-ready-for-production) - Set up Xcode schemes and [API keys](/docs/appendix/glossary#api-key "Secret header credential for REST calls; v5 keys use scoped permissions.") for your production build * [Apple Best Practices and Guidelines](/docs/appendix/apple-best-practices-and-guidelines) - UX patterns, ProximityReader usage, and security practices * [Running Payments](/docs/setting-up-the-ios-sdk/running-payments) - SDK reference for payment flows _This guide reflects Apple's published requirements as of March 2025 (App Review Checklist v1.5.1) and September 2023 (Getting Started v1.2). Apple updates these documents periodically. If you see a discrepancy, the Apple source wins — please flag it to us._ # Retrieving Your API Key To retrieve your [API key](/docs/appendix/glossary#api-key "Secret header credential for REST calls; v5 keys use scoped permissions.") for your account, follow these steps: **1. Navigate to the Developer page** In the sidebar, click on ![link icon](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiIGNsYXNzPSJsdWNpZGUgbHVjaWRlLWZpbGUtdGV4dC1pY29uIGx1Y2lkZS1maWxlLXRleHQiPjxwYXRoIGQ9Ik02IDIyYTIgMiAwIDAgMS0yLTJWNGEyIDIgMCAwIDEgMi0yaDhhMi40IDIuNCAwIDAgMSAxLjcwNC43MDZsMy41ODggMy41ODhBMi40IDIuNCAwIDAgMSAyMCA4djEyYTIgMiAwIDAgMS0yIDJ6Ii8+PHBhdGggZD0iTTE0IDJ2NWExIDEgMCAwIDAgMSAxaDUiLz48cGF0aCBkPSJNMTAgOUg4Ii8+PHBhdGggZD0iTTE2IDEzSDgiLz48cGF0aCBkPSJNMTYgMTdIOCIvPjwvc3ZnPg==) Developer to access the Developer Tools page. **2. Access API Keys** Once on the Developer page, you'll see the **API Keys** tab. Click on it to view your API key management section. **3. View your API key** Your API key will be displayed in the API Key Management section. You can: * View the masked API key * Click the eye icon to reveal the full key * Click the copy icon to copy the key to your clipboard ![API Key Management](/api.png) **Security Note**: Keep your API key confidential and never share it in client-side code or public repositories. Rotate and revoke keys regularly to maintain security. ## Using Your API Key in the SDK Pass your API key to the SDK when you initialize it, along with a `KoardOptions` value that selects the environment and logging level: ```swift import KoardSDK let options = KoardOptions( environment: .uat, // .uat | .production | .custom(String) loggingLevel: .debug // .none | .error | .warning | .debug | .verbose ) KoardMerchantSDK.shared.initialize(options: options, apiKey: "your-koard-api-key") ``` * **`environment`** accepts `.uat`, `.production`, or `.custom(String)` for a custom base URL. * **`loggingLevel`** accepts `.none`, `.error`, `.warning`, `.debug`, or `.verbose`. Use a lower level (such as `.error` or `.none`) for production builds. Call `initialize(options:apiKey:)` once, early in your app lifecycle (for example in `AppDelegate`), before using any payment functionality. # Running Payments These examples target the published **iOS SDK 1.0.21** and the standard merchant checkout flow: sale, preauth, refunds, and transaction management. [Install this version](/docs/setting-up-the-ios-sdk/installing-the-sdk) before using them. If you're ready to start developing, see our [SDK installation guide](/docs/setting-up-the-ios-sdk/installing-the-sdk). [Get started with Koard](/docs/introduction) **What you learn** In this guide, you'll learn: * How to initialize and authenticate with the Koard Merchant SDK * How to set up location management for multi-location merchants * How to prepare card reader sessions for tap-to-pay * How to process different types of transactions (sale, [preauth](/docs/appendix/glossary#preauth "Authorization hold without capture; must later capture or reverse."), refund) * How to handle transaction responses and error scenarios For custom order/refund references, see [Transaction Metadata](/docs/setting-up-the-ios-sdk/transaction-metadata). For `deinit()`, initialization status, and signing in after a reset, see [SDK Lifecycle and Deinitialization](/docs/setting-up-the-ios-sdk/sdk-lifecycle). ## Active Location and Terminal Routing **Your app must provide a way to select the active location in settings.** Authenticate the merchant, load that merchant's available locations, and let the operator select the location where the device is taking payments. The selected `location_id` resolves the linked `terminal_id`, whose processor configuration, MID, and TID determine payment routing for that merchant. Show the active location in settings and on the payment screen. Select it before reader preparation or any payment. If the operator changes merchants, clear the previous selection and reload the new merchant's locations. If you remember a choice between sessions, validate that it still belongs to the authenticated merchant and still has a usable linked terminal. Do not silently reuse another merchant's location or select an arbitrary terminal. * **iOS:** call `KoardMerchantSDK.shared.setActiveLocationID(location.id)`, then follow the account-linking and reader preparation flow. * **Android:** call the suspending `sdk.setActiveLocation(location.id)`, then `sdk.enrollDevice()` and the reader preparation flow. Block payment initiation until location selection and required readiness checks have completed. If a location's terminal changes, reload the configuration and complete the SDK's readiness flow before taking another payment. ## Before you begin This comprehensive guide covers everything you need to know about integrating and using the KoardMerchantSDK in your iOS application. For payment concepts and API payloads, explore the [Payments guides](/docs/payments/overview)—including [Sale](/docs/payments/methods/sale) and [Preauth](/docs/payments/methods/preauth). To understand the complete flow, see the [Payment Lifecycle guide](/docs/payments/payment-lifecycle). **Test on Real Hardware**: Keep a dedicated test iPhone with your [Sandbox Apple Account](/docs/appendix/glossary#sandbox-apple-account "Dedicated Apple tester account on a test iPhone for certification and QA.") signed in. Simulator builds cannot exercise [Tap to Pay](/docs/appendix/glossary#tap-to-pay "Apple contactless acceptance on iPhone without extra hardware."), and production Apple IDs won't work in the Sandbox environment. ## Key Concepts ### 1. Authentication Tokens The SDK manages several types of tokens automatically: * **[API Key](/docs/appendix/glossary#api-key "Secret header credential for REST calls; v5 keys use scoped permissions.")**: Your API key for Koard services * **Card Reader Token**: Apple's ProximityReader token for Tap to Pay functionality ### 2. Card Reader Sessions The SDK handles Apple's ProximityReader lifecycle: * **Preparation**: Refreshes tokens and prepares the reader for transactions * **Transaction Processing**: Manages card reading and data collection * **Session Management**: Handles background/foreground transitions automatically ### 3. Location Management Multi-location merchants must set an active location before processing payments: * **Retrieve available locations** after login * **Set the active location** for all subsequent transactions * **Location data is persisted** across app sessions ## Initialize the SDK Initialize the SDK early in your app lifecycle (typically in `AppDelegate` or `SceneDelegate`): ```swift import UIKit import KoardSDK class AppDelegate: NSObject, UIApplicationDelegate { func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { // Configure SDK options let options = KoardOptions( environment: .uat, // .uat | .production | .custom(String) loggingLevel: .debug // .none | .error | .warning | .debug | .verbose ) // Initialize with your API key KoardMerchantSDK.shared.initialize( options: options, apiKey: "your-koard-api-key" ) return true } } ``` ## Authenticate the Merchant Before processing any payments, authenticate the merchant. The login function returns a JWT token that is then passed in the Bearer token of all successive requests. ```swift import KoardSDK private func authenticateMerchant() async throws { do { // Login with merchant credentials try await KoardMerchantSDK.shared.login( code: "your-merchant-code", pin: "your-merchant-pin" ) print("Merchant authenticated successfully") // Load this merchant's locations into your app settings picker. // Call setupLocation(selectedLocationId:) after the operator selects one. } catch { print("Authentication failed: \(error)") throw error } } ``` If you have already resolved the merchant identity into a single opaque string (for example via a QR scan, SSO callback, or server-issued provisioning token), you can log in with an alias instead of a code and PIN. It produces the same session token: ```swift try await KoardMerchantSDK.shared.login(alias: "your-merchant-alias") ``` **Session-token auth**: Login is session-token only. The SDK persists the session token and never stores the merchant code, PIN, or alias. When the session expires, call `login(...)` again to re-authenticate. ## Set Location Retrieve and set the location selected in your app settings. Its linked terminal determines the MID, TID, and processor configuration used for payments. Revalidate a saved selection after login or a merchant change; never automatically select the first location. Block payments until the selected location belongs to the authenticated merchant and has a linked terminal. ```swift private enum LocationSelectionError: Error { case invalidSelection case noLinkedTerminal } private func setupLocation(selectedLocationId: String) async throws { do { let sdk = KoardMerchantSDK.shared let merchant = try await sdk.getMerchantAccount() let locations = try await sdk.locations(request: GetLocationsRequest( locationId: selectedLocationId, merchantId: merchant.id )) guard let activeLocation = locations.first(where: { $0.id == selectedLocationId && $0.accountId == merchant.id }) else { throw LocationSelectionError.invalidSelection } guard let terminalId = activeLocation.terminalId, !terminalId.isEmpty else { throw LocationSelectionError.noLinkedTerminal } sdk.setActiveLocationID(activeLocation.id) print("Active location set: \(activeLocation.name)") } catch { print("Location setup failed: \(error)") throw error } } ``` ## Prepare a Card Reader Session After login and active-location selection, call `prepare()`. If it throws `accountNotLinked`, show an explicit **Accept Tap to Pay terms** action. Await `linkAccountAsync()` from that action, then prepare again. Linking can be declined or fail; keep payments blocked until preparation succeeds. ```swift @MainActor private func prepareCardReader() async throws { try await KoardMerchantSDK.shared.prepare() } // Call from the operator's account-linking action, not a background retry. @MainActor private func linkAndPrepareCardReader() async throws { try await KoardMerchantSDK.shared.linkAccountAsync() try await prepareCardReader() } ``` The SDK owns the reader's event subscription. Display readiness by polling `KoardMerchantSDK.shared.status` while the payment view is visible, as shown in [Session Management](#session-management). Do not add a second `readerEvents` iterator. ## Process Sale Transactions Sale transactions are single-step auth + capture that immediately capture funds. Use sales when the final amount is known at payment time. For more details on when to use Sale vs Preauth, compare the [Sale](/docs/payments/methods/sale) and [Preauth](/docs/payments/methods/preauth) guides. ```swift @MainActor private func processSale(eventId: String) async throws { // Create payment breakdown (optional) let breakdown = PaymentBreakdown( subtotal: 1000, // $10.00 in cents taxRate: 8.75, // 8.75% as a percent value (not a 0-1 decimal) taxAmount: 88, // $0.88 in cents tipAmount: 200, // $2.00 in cents tipType: .fixed // or .percentage ) // Create currency let currency = CurrencyCode(currencyCode: "USD", displayName: "US Dollar") do { // Process the sale let response = try await KoardMerchantSDK.shared.sale( amount: 1288, // Total amount in cents breakdown: breakdown, // Optional breakdown currency: currency, eventId: eventId, // Persisted before submitting this operation type: .sale, // Defaults to .sale metadata: ["order_id": "order-123", "channel": "mobile"] ) // Handle the response try handleTransactionResponse(response) } catch { print("Sale failed: \(error)") throw error } } ``` **Tracking and [idempotency](/docs/appendix/glossary#idempotency "Safe retry pattern using event_id so network failures do not double-charge.")**: `sale` and `preauth` take an optional `eventId` (UUID4), not a `transactionId`. Koard generates the transaction ID and returns it on `response.transactionId`. The reader is driven internally, so a Tap to Pay sheet is presented during these calls. Persist each operation ID in your app’s order store before calling the SDK. The helpers accept those saved IDs; confirmations and second-card payments use their own IDs. If the response is lost, [reconcile the saved ID](/docs/payments/idempotency) before considering another submission. ## Process Preauthorization Transactions Preauthorization transactions authorize funds without capturing them. They can be incrementally authorized, captured, or reversed. Use preauth when the final amount is uncertain (e.g., restaurant with tip) or when you need to verify funds availability. To complete a preauth, capture the payment using the transaction ID. For the complete flow, see [Preauth](/docs/payments/methods/preauth) and [Capture](/docs/payments/methods/capture). ```swift @MainActor private func processPreauth(eventId: String) async throws { let currency = CurrencyCode(currencyCode: "USD", displayName: "US Dollar") do { // Process preauthorization (breakdown is optional — pass nil) let response = try await KoardMerchantSDK.shared.preauth( amount: 1000, // Amount to preauthorize in cents breakdown: nil, // Optional breakdown currency: currency, eventId: eventId, // Persisted before submitting this operation metadata: ["order_id": "order-123"] ) try handleTransactionResponse(response) // Store transaction ID for later capture/reverse UserDefaults.standard.set(response.transactionId, forKey: "lastPreauthId") } catch { print("Preauth failed: \(error)") throw error } } ``` ## Handle Transaction Responses Payment methods return a `TransactionResponse`. Read the rich domain object from `response.transaction` (a `KoardTransaction?`). Its `status` is the public `KoardTransaction.Status` enum, which includes `pending`, `authorized`, `captured`, `surchargePending`, `surchargeApplied`, `approved`, `declined`, `refunded`, `reversed`, `pickupCard`, `timedOut`, `canceled`, `cancelled`, `error`, `settled`, and `unknown`. ```swift private enum PaymentError: Error { case invalidResponse } private func requireTransaction(_ response: TransactionResponse) throws -> KoardTransaction { guard let transaction = response.transaction else { throw PaymentError.invalidResponse } return transaction } @MainActor private func handleTransactionResponse(_ response: TransactionResponse) throws { renderChargeResult(try requireTransaction(response)) } @MainActor private func renderChargeResult(_ transaction: KoardTransaction) { // A partial approval is not payment of the entire order. if transaction.isPartialApproval { requestPartialApproval(transaction) return } switch transaction.status { case .captured, .authorized, .approved, .surchargeApplied, .settled: showReceipt(transaction) case .surchargePending: requestSurchargeConfirmation(transaction) case .pending: reconcileTransaction(transaction) case .declined: showDeclined(transaction.statusReason ?? "Transaction declined") case .canceled, .cancelled: showCanceled("Transaction canceled") case .error, .timedOut, .pickupCard, .reversed, .refunded, .unknown: showUnresolved(transaction.statusReason ?? "Check the transaction outcome") @unknown default: showUnresolved("Unrecognized transaction status") } } @MainActor private func confirmSurcharge(transactionId: String, accepted: Bool, eventId: String) async throws { // eventId is the separately persisted cardholder-decision operation ID. let transaction = try await KoardMerchantSDK.shared.confirm( transaction: transactionId, confirm: accepted, eventId: eventId ) // confirm returns KoardTransaction directly, not TransactionResponse. renderChargeResult(transaction) } ``` The `show...`, `request...`, and `reconcileTransaction` functions are application UI callbacks. Save the transaction ID before displaying the next screen. For surcharge confirmation, display `surchargeDisclosure`, the surcharge, and the final total from the returned transaction; persist a new decision ID before calling `confirmSurcharge`. Pass `accepted: false` when the cardholder rejects the fee. See [pending amounts and expiry](/docs/payments/surcharging#pending-amounts-rejection-and-expiry) for the 120-second confirmation window and cancellation outcomes. An authorized preauth is a hold, so its receipt must say **authorized**, rather than captured or settled. Pending, missing, and partial results must retain their IDs for [outcome recovery](/docs/payments/idempotency); do not automatically submit another sale. ## Handle Partial Approvals When the issuer authorizes less than the requested amount, the transaction comes back with a `statusReason` of `partial_approval` (the `StatusReason.partialApproval` case). The top-level `status` still reads as `approved`/`captured`. Use the `partialAuthApproval` method to accept the partial amount as final, or reject it to release the hold: ```swift private func handlePartialApproval(_ transaction: KoardTransaction, eventId: String) async throws { guard transaction.isPartialApproval else { return } // remainingAmount is the amount still owed after the partial authorization if let remaining = transaction.remainingAmount { print("Partial approval — remaining balance: \(remaining) cents") } // Accept the partial amount as final (or pass approve: false to release the hold) let settled = try await KoardMerchantSDK.shared.partialAuthApproval( transactionId: transaction.transactionId, approve: true, eventId: eventId // Persisted partial-approval decision ID ) print("Partial-approval decision status: \(settled.status.string)") } ``` To collect the remaining balance on a second card, pass the original transaction's id as `partialAuthTransactionId` on the follow-up `sale` (or `preauth`). This links the second-card payment to the original partial approval. It opens a new Tap to Pay read; inspect the remainder's returned transaction too: ```swift let remainder = try await KoardMerchantSDK.shared.sale( amount: remainingAmount, breakdown: nil, currency: CurrencyCode(currencyCode: "USD", displayName: "US Dollar"), eventId: remainderEventId, // Separately persisted second-card operation ID partialAuthTransactionId: transaction.transactionId ) ``` ## Transaction Management These helpers return the actual `KoardTransaction`. The caller must inspect its `status` and operation amounts before showing success. A returned API response alone is not an approval; pending outcomes need reconciliation. ### Refund a Transaction ```swift import KoardSDK private func processRefund(transactionId: String, eventId: String, amount: Int? = nil) async throws -> KoardTransaction { do { let response = try await KoardMerchantSDK.shared.refund( transactionId: transactionId, amount: amount, // nil for full refund eventId: eventId, // Persisted refund operation ID metadata: ["order_id": "order-123", "reason": "customer_return"] ) return try requireTransaction(response) } catch { print("Refund failed: \(error)") throw error } } ``` To run a card-present refund (presenting the Tap to Pay sheet and capturing card data with the refund), pass `withTap: true`. When `withTap` is set, an `amount` is required: ```swift let response = try await KoardMerchantSDK.shared.refund( transactionId: transactionId, amount: 1288, // Required when withTap is true eventId: eventId, withTap: true ) ``` ### Reverse a Preauthorization ```swift private func reversePreauth(transactionId: String, eventId: String, amount: Int? = nil) async throws -> KoardTransaction { do { let response = try await KoardMerchantSDK.shared.reverse( transactionId: transactionId, amount: amount, // nil for full reversal eventId: eventId ) return try requireTransaction(response) } catch { print("Reversal failed: \(error)") throw error } } ``` **Note**: Transactions can be partially reversed and refunded. Inspect the returned status and cumulative reversed/refunded amounts. Do not infer cancellation solely from a zero remaining amount; REST uses `canceled`, while SDK enums can include legacy aliases. For more details, see our [Payment Lifecycle guide](/docs/payments/payment-lifecycle). ### Incremental Authorization Authorize additional amounts on an existing preauth transaction. This is useful for adding incidental charges (e.g., hotel mini bar, additional restaurant items): ```swift private func incrementalAuth(transactionId: String, additionalAmount: Int, eventId: String) async throws -> KoardTransaction { do { let response = try await KoardMerchantSDK.shared.auth( transactionId: transactionId, amount: additionalAmount, breakdown: nil, eventId: eventId ) return try requireTransaction(response) } catch { print("Incremental auth failed: \(error)") throw error } } ``` ### Capture a Transaction Capture a previously authorized preauth transaction. You can capture the full authorized amount or a partial amount (e.g., adjust for final tip): ```swift private func captureTransaction(transactionId: String, eventId: String, finalAmount: Int? = nil) async throws -> KoardTransaction { do { let response = try await KoardMerchantSDK.shared.capture( transactionId: transactionId, amount: finalAmount, // nil to capture full authorized amount breakdown: nil, // Supply a breakdown matching the final amount if needed eventId: eventId ) return try requireTransaction(response) } catch { print("Capture failed: \(error)") throw error } } ``` ### Adjust the Tip Adjust the tip on a completed transaction (for example, after a customer finalizes a tip in table service): ```swift private func adjustTip(transactionId: String, newTipTotal: Int, eventId: String) async throws -> KoardTransaction { let response = try await KoardMerchantSDK.shared.tipAdjust( transactionId: transactionId, amount: newTipTotal, // New tip amount in cents tipType: .fixed, // .fixed or .percentage (PaymentBreakdown.TipType) eventId: eventId // Persisted tip-adjust operation ID ) return try requireTransaction(response) } ``` ### Send a Receipt Deliver a receipt for a completed transaction by email, SMS, or both. Pass at least one of `email` / `phoneNumber`: ```swift private func sendReceipt(transactionId: String) async throws { let response = try await KoardMerchantSDK.shared.sendReceipts( transactionId: transactionId, email: "customer@example.com", phoneNumber: "+15551234567" ) print("Receipt delivery: \(response)") } ``` ### Create a Fallback Payment Link When a tap cannot complete — an unsupported card, a reader problem, or a customer who would rather pay on their own device — generate a hosted payment link for the same amount: ```swift private func createFallback(amount: Int, breakdown: PaymentBreakdown?) async throws { let fallback = try await KoardMerchantSDK.shared.createFallbackLink( amount: amount, breakdown: breakdown ) print("Fallback link: \(fallback)") // Share the link with the customer (SMS, email, or QR code) } ``` ## Transaction History The SDK provides methods to retrieve and filter transaction history: ```swift import KoardSDK private func getTransactionHistory() async throws { do { // Get recent transactions let history = try await KoardMerchantSDK.shared.transactionHistory() print("Found \(history.transactions.count) transactions") // Filter by status let approvedTransactions = try await KoardMerchantSDK.shared.transactionsByStatus("approved") // Search transactions let searchResults = try await KoardMerchantSDK.shared.searchTransactions("card_number_here") // Advanced filtering let filteredTransactions = try await KoardMerchantSDK.shared.searchTransactionsAdvanced( startDate: Date().addingTimeInterval(-86400 * 7), // Last 7 days endDate: Date(), statuses: ["approved", "declined"], types: ["sale", "refund"], minAmount: 100, // $1.00 maxAmount: 10000, // $100.00 limit: 50 ) } catch { print("Transaction history failed: \(error)") throw error } } ``` **Note**: For webhook-based transaction monitoring, see our [Available Events guide](/docs/webhooks/available-events). ## Error Handling SDK calls throw `KoardMerchantSDKError`. Use its `errorDescription` property for a user-facing message. Match the cases you care about: ```swift private func handleSDKError(_ error: Error) { if let koardError = error as? KoardMerchantSDKError { switch koardError { case .missingLocationID: print("No active location set") // Prompt user to select location case .accountNotLinked: print("Tap to Pay account is not linked") // Show the account-linking action; it awaits // linkAndPrepareCardReader() and handles any thrown error. case .readerTokenInvalid(let message): print("Reader token invalid: \(message ?? "retry preparation")") // Usually transient — retrying prepare() recovers case .unauthorized: print("Not authenticated or session expired") // Redirect to login case .blockedAccount: print("This merchant account is blocked") case .rateLimited(let message): print("Rate limited: \(message ?? "slow down and retry")") // Back off and retry later case .network(let description, _): print("Network error: \(description)") case .server(let message): print("Server error: \(message ?? "try again later")") case .TTPPaymentFailed(.canceled): print("Customer canceled at the Tap to Pay sheet") // Benign outcome — treat as canceled, not a failure case .TTPPaymentFailed(let ttpError): print("Tap to Pay error: \(ttpError)") // Handle other reader errors case .invalidParameters(let message): print("Invalid parameters: \(message)") default: print("Koard SDK error: \(koardError.errorDescription)") } } else { print("General error: \(error)") } } ``` **Error type**: The SDK's error type is `KoardMerchantSDKError`. Earlier releases added `.rateLimited(message:)` for HTTP 429 and `TTPPaymentError.canceled` for Tap to Pay sheet cancellations. Network/transport failures are thrown as `.network(description:underlying:)` rather than raw `URLError`, and a missing session on a payment/refund/pre-auth throws `.unauthorized`. **Upgrading to 1.0.20**: Two behavior changes require code updates.\ \ **1. `prepare()` no longer auto-links the Tap to Pay account.** It now throws `KoardMerchantSDKError.accountNotLinked` instead. Guard for that case and drive linking explicitly with `linkAccount()` (or `linkAccountAsync()`), then call `prepare()` again.\ \ **2. `linkAccountAsync()` now throws** when linking fails or is declined — previously it never threw. Wrap it in `try`/`catch` and keep showing your "link account" prompt on failure.\ \ `prepare()` can also throw the new `.readerTokenInvalid`; a retry typically recovers. ## Session Management The SDK manages the reader lifecycle. The standard demo polls the public status rather than subscribing to the reader event stream. A SwiftUI `.task` stops when its view disappears, so polling does not accumulate across navigation: ```swift import SwiftUI import KoardSDK struct ReaderStatusView: View { @State private var statusMessage = "Reader not ready" @State private var isReady = false var body: some View { Text(statusMessage) .accessibilityValue(isReady ? "Ready" : "Not ready") .task { @MainActor in while !Task.isCancelled { let status = KoardMerchantSDK.shared.status statusMessage = status.info isReady = status.isReady do { try await Task.sleep(nanoseconds: 250_000_000) } catch { return } } } } } ``` Use readiness to gate the payment button. Prepare after login, a location change, or a readiness failure, and handle `accountNotLinked` through the operator's explicit linking action. Avoid launching overlapping preparation tasks when the app returns to the foreground. ## Logout and Cleanup `logout()` signs out the merchant while leaving the SDK initialized. To also clear the SDK API key and require initialization again, call `KoardMerchantSDK.shared.deinit()`. Read `KoardMerchantSDK.shared.isInitialized` to display that state. See [SDK Lifecycle and Deinitialization](/docs/setting-up-the-ios-sdk/sdk-lifecycle) for the reset sequence and enrollment behavior. ```swift private func logout() { // Clear all session data KoardMerchantSDK.shared.logout() print("Logged out successfully") // Redirect to login screen } ``` ## Best Practices ### SDK Management * **Token Management**: The SDK handles all token refresh automatically * **Error Handling**: Always wrap SDK calls in try-catch blocks * **Background Handling**: The SDK manages background transitions automatically * **Session Preparation**: Call `prepare()` before each payment session ### Payment Processing * **Amount Formatting**: Always use minor currency units (e.g., 1050 cents for $10.50) * **Include Breakdowns**: Provide detailed breakdowns for accurate tax and tip reporting * **Location Setting**: Set active location before any payment operations * **Store Transaction IDs**: Save transaction IDs for all follow-up operations ### User Experience * **Monitor Reader Status**: Read the SDK status for readiness; leave its reader event subscription to the SDK * **Handle All States**: Implement handlers for all transaction states * **Provide Clear Feedback**: Show clear messages for declined or failed transactions ### Gateway Considerations * **Know Your Gateway**: Different gateways (TSYS, Payroc) have different features * **Batch Management**: Understand your gateway's batch requirements * **Response Codes**: Response codes vary by gateway For more best practices, see: * [Payment Lifecycle guide](/docs/payments/payment-lifecycle) * [Sale](/docs/payments/methods/sale) and [Preauth](/docs/payments/methods/preauth) * [Capture](/docs/payments/methods/capture) and [Refund](/docs/payments/methods/refund) ## Troubleshooting ### Common Issues **Account Linking Issues** * Ensure device has iCloud account configured * Verify device has passcode enabled * Check that device supports Apple Tap to Pay on iPhone **Token Expiration** * SDK automatically refreshes tokens * Check network connectivity * Verify API key is valid **Card Reader Not Ready** * Call `prepare()` before processing payments * Ensure merchant is authenticated with `login()` * Check that account is linked with `isAccountLinked()` **Missing Location** * Verify location is set with `setActiveLocationID()` * Ensure location has valid terminal configuration * Check that location belongs to authenticated merchant **Transaction Errors** * Check transaction state before performing operations * Verify amounts are within valid ranges * Review gateway response for detailed error information For more troubleshooting help, see: * [Payment Lifecycle guide](/docs/payments/payment-lifecycle) * [Incremental Auth](/docs/payments/methods/incremental-auth) and [Tip Adjust](/docs/payments/methods/tip-adjust) * [Reverse](/docs/payments/methods/reverse) and [Refund](/docs/payments/methods/refund) ## Requirements * **iOS 17.4+** * **Xcode 16.3+** * **Swift 5.9+** ## See also This wraps up payment processing with the iOS SDK. See the links below for next steps in your integration: * [Payment Lifecycle](/docs/payments/payment-lifecycle) - Complete payment flow guide * [Adding Tap to Pay](/docs/setting-up-the-ios-sdk/adding-support-for-tap-to-pay-on-iphone) - Enable contactless payments * [Installing the SDK](/docs/setting-up-the-ios-sdk/installing-the-sdk) - SDK installation guide * [Creating a Sandbox Apple Account](/docs/setting-up-the-ios-sdk/creating-a-sandbox-apple-account) - Maintain test identities * [Getting Ready for Production](/docs/setting-up-the-ios-sdk/getting-ready-for-production) - Switch schemes and API keys * [Apple Best Practices](/docs/appendix/apple-best-practices-and-guidelines) - iOS development guidelines # SDK Lifecycle and Deinitialization `deinit()` ends the SDK's current setup so your app can initialize it again without restarting. Use it when replacing the API key, changing environments, resetting the merchant session, or testing initialization and sign-in again. It is available in **iOS SDK 1.0.21 and later** in UAT and Production. This is an explicit Koard SDK method. Calling `KoardMerchantSDK.shared.deinit()` does not destroy the Swift singleton or require your app to release it. ## Choose the right cleanup operation | Operation | What it does | What remains | |---|---|---| | `deinitializeCardReader()` | Drops the reader session, resets status to `.notReady`, and recreates the reader | SDK configuration and merchant authentication | | `logout()` | Clears merchant authentication, saved location/session data, the reader session, and its cached token | SDK initialization and its API key | | `deinit()` | Performs logout, clears the API-key provider and in-memory key, and sets initialization to `false` | The shared SDK object and device enrollment | Use `logout()` for an ordinary sign-out. Use `deinit()` when the next session must start with a fresh SDK configuration. Neither operation deletes Koard transaction records or revokes the API key on the server. ## What deinit does The SDK calls `logout()` first. Logout clears the merchant token and SDK-owned session/location storage, drops the reader session, resets reader status to `.notReady`, and invalidates the cached card-reader token. Deinit then clears the API key and marks the SDK uninitialized. The method is synchronous and does not throw. It resets SDK state; it is not a payment cancellation or refund operation. Call it after any active payment and reader preparation have finished. Unlike Android deinit, iOS deinit **does not unenroll the device or remove its enrollment key/certificate**. It also does not unlink the merchant's Apple Tap to Pay account. Check account linking and reader readiness again after signing in; preserved enrollment does not mean a new merchant is linked or ready. ## Check initialization state ```swift import KoardSDK let sdkInitialized = KoardMerchantSDK.shared.isInitialized ``` `isInitialized` is a Boolean property, not a function. It returns `false` before `initialize(options:apiKey:)` and after `deinit()`. Read it to show the SDK's configuration state on a login or settings screen. Check `isAuthenticated` for merchant sign-in and `status` for reader readiness separately. Initializing the SDK does not authenticate a merchant or prepare the card reader. ## Deinitialize and initialize again ```swift import KoardSDK @MainActor func deinitializeSdk() { KoardMerchantSDK.shared.deinit() assert(!KoardMerchantSDK.shared.isInitialized) } @MainActor func initializeSdk(apiKey: String, options: KoardOptions) { let sdk = KoardMerchantSDK.shared if !sdk.isInitialized { sdk.initialize(options: options, apiKey: apiKey) } } ``` Pass `KoardOptions(environment: .uat, loggingLevel: .error)` for UAT or `.production` for Production, together with the matching API key. Your app must supply the key again from its existing configuration or credential mechanism. Deinit clears the SDK's copy, not the app's configuration. Calling `initialize()` while already initialized leaves the current configuration in place. To replace it, complete deinit first and then initialize. Keep lifecycle actions sequential and prevent new payment/preparation work during the transition. After reinitialization, [log in and select an active location](running-payments.md), check account linking, and prepare the reader before accepting payments. There is no automatic restoration of the old merchant login after deinit. ## Verify the flow in the demo Update the [standard demo](demo.md) to SDK 1.0.21 before adding lifecycle controls. Call `deinitializeSdk()` after payment work finishes, disable payment controls, then call `initializeSdk()` with the demo's current environment and API key. Sign in, select a location, and prepare the reader again. Display initialization separately from authentication and reader readiness. The app's retained configuration permits reinitialization; it does not mean deinit kept the SDK's credentials. ## See also - [Transaction Metadata](transaction-metadata.md) - [Running Payments](running-payments.md) - [Android SDK Lifecycle](../../android-sdk/details/sdk-lifecycle.md) — Android clears enrollment during deinit # Transaction Metadata on iOS Attach your own JSON context to a sale, preauthorization, or refund with the optional `metadata` parameter. It is available in **iOS SDK 1.0.21 and later** in UAT and Production. [Install or update the SDK](installing-sdk.md) before using these examples. ## What is stored Metadata is an optional JSON object containing your application's context, such as an order ID, invoice reference, cart ID, or refund reason. Use it to reconcile Koard events with your own records and explain why a payment operation occurred. It belongs to the **individual transaction event** that the request creates. It is separate from `eventId` (idempotency), `transactionId` (the payment lifecycle), and payment settings such as amount, tax, tip, or surcharge. Putting those settings in metadata does not configure the payment. - Use string keys and JSON values: strings, numbers, booleans, nulls, arrays, or nested objects. Convert dates and custom application objects to JSON-compatible values first. - Omitting metadata stores no custom metadata for that event. An empty object `{}` is valid and is distinct from no value. - A refund can carry different metadata from the original sale. Later events do **not** automatically copy or merge the previous event's metadata. Pass the order reference again when you want it on a refund. - Metadata is stored by Koard for retrieval. It is not a processor configuration field or a guarantee of text appearing on a receipt. - Include business references only; do not include card numbers, CVVs, PINs, API keys, session tokens, or other secrets. ## Send metadata with a sale or preauth Use a Swift dictionary of type `[String: Any]` whose values can be encoded as JSON. The example assumes the SDK is initialized, the merchant is signed in, a location is selected, and the reader is ready. ```swift import KoardSDK @MainActor func saleWithMetadata(eventId: String) async throws -> TransactionResponse { let metadata: [String: Any] = [ "order_id": "order-123", "channel": "mobile", "fulfillment": ["pickup": true] ] return try await KoardMerchantSDK.shared.sale( amount: 1250, // $12.50 in minor currency units breakdown: nil, currency: CurrencyCode(currencyCode: "USD", displayName: "US Dollar"), eventId: eventId, metadata: metadata ) } ``` Use a UUID4 for `eventId` and follow the [idempotency guidance](../../payments/details/idempotency.md) for retries. Handle the returned payment status as in [Running Payments](running-payments.md) and save `response.transactionId`; a returned response alone does not mean the payment was approved. For an authorization without capture, replace `sale(...)` with `preauth(...)`; the `amount`, `breakdown`, `currency`, `eventId`, and `metadata` arguments above are the same. Run the sale or the preauth according to your payment flow. ## Send metadata with a refund ```swift import KoardSDK @MainActor func refundWithMetadata( transactionId: String, eventId: String ) async throws -> TransactionResponse { try await KoardMerchantSDK.shared.refund( transactionId: transactionId, amount: 500, // $5.00; nil requests a full refund eventId: eventId, metadata: [ "order_id": "order-123", "return_id": "return-456", "reason": "customer_return" ] ) } ``` The default refund does not request another tap. To use a card-present refund, supply `withTap: true` before `metadata`; the same dictionary is included with that refund request. ## How it reaches Koard The SDK encodes your dictionary into the payment request's `metadata` JSON field alongside the encrypted reader data. Sale/preauth use the v5 payment request; refunds carry metadata in their refund request. You do not need to construct the SDK's `AnyCodable` wrapper or encode the dictionary yourself. Omit the parameter or pass `nil` when no custom data is needed. Unsupported values such as custom class instances cause JSON encoding to fail; use strings or JSON objects for those values. Metadata does not change the Apple reader UI, requested amount, or cardholder verification. ## Retrieve and view metadata The current SDK transaction response models do not expose a typed `metadata` property. Retrieve the raw transaction JSON through your backend or an API client using an API key authorized to read that merchant's transactions. Save the returned transaction ID, then call `GET /v1/transactions/{transaction_id}`. The top-level `metadata` belongs to the current event; `history[].metadata` belongs to each earlier event. For one specific operation, call `GET /v1/transactions/event/{event_id}` using the event ID sent with that operation. For example, from your backend or a local API client with `KOARD_API_KEY` set securely: ```bash KOARD_BASE_URL="https://api.uat.koard.com" TRANSACTION_ID="your-transaction-id" curl --fail-with-body --silent --show-error \ "$KOARD_BASE_URL/v1/transactions/$TRANSACTION_ID" \ -H "x-koard-apikey: $KOARD_API_KEY" \ -H "Accept: application/json" ``` For Production, use `https://api.koard.com` and a production key. A transaction ID and key must belong to the same environment and authorized account scope. A response after a refund may contain these fields (other transaction fields omitted): ```json { "transaction_id": "your-transaction-id", "event_id": "refund-event-id", "transaction_type": "refund", "metadata": { "order_id": "order-123", "return_id": "return-456", "reason": "customer_return" }, "history": [ { "event_id": "sale-event-id", "metadata": { "order_id": "order-123", "channel": "mobile" } } ] } ``` If a later event has `metadata: null`, check the original event in `history` or use its event lookup; null at the top level does not mean the sale's metadata was deleted. Likewise, older transactions and requests that omitted metadata can legitimately return null. To display metadata inside your app, have your backend retrieve it and return the fields your UI needs. Do not assume the SDK demo receipt or MMS transaction screen displays arbitrary metadata; the raw API response is the verification path described here. ## Verify in UAT and Production 1. Send a sale or preauth with a recognizable `order_id` and retain its event ID and returned transaction ID. 2. Retrieve that event in the same environment and confirm the metadata values and JSON types. 3. If testing a refund, pass a separate `return_id` and retain the sale's `order_id` explicitly. Retrieve the refund event and the transaction history to compare them. 4. Reinitializing or deinitializing the SDK does not erase metadata already stored in Koard. Log in again before continuing SDK operations. ## See also - [SDK Lifecycle and Deinitialization](sdk-lifecycle.md) - [Running Payments](running-payments.md) - [Android Transaction Metadata](../../android-sdk/details/transaction-metadata.md) # Koard iOS Demo App Use the standard merchant demo for login, active-location selection, reader preparation, sale/preauth, tips, refunds, and receipts. These documentation samples target **iOS SDK 1.0.21**. The demo obtains `KoardSDK` through **Swift Package Manager** from the published `https://github.com/koardlabs/koard-ios.git` package. No local SDK source checkout or manually installed framework is required. ## Set up the demo 1. Clone [koardlabs/demos](https://github.com/koardlabs/demos) and open its `ios` directory: ```bash git clone https://github.com/koardlabs/demos.git cd demos/ios cp KoardMerchantSDK-Demo/Config.plist.template KoardMerchantSDK-Demo/Config.plist ``` 2. Edit `KoardMerchantSDK-Demo/Config.plist` with your SDK API key for each environment: ```xml UAT apiKeyYOUR_UAT_API_KEY PRODUCTION apiKeyYOUR_PROD_API_KEY ``` Obtain keys from the developers page in [UAT](https://app.uat.koard.com/developers) or [Production](https://app.koard.com/developers). Keep the real plist out of source control. Enter the merchant code and PIN on the app's login screen; they are not plist settings. 3. Open `ios/KoardDemo.xcodeproj` from the repository root. In the project's **Package Dependencies**, set `koard-ios` to **Exact Version 1.0.21**, then resolve packages. The public demo checkout may still pin 1.0.20; update that pin before using metadata or SDK reset examples. 4. Select **Koard Demo (UAT)** for testing. **Koard Demo (Production)** uses production credentials and can route real payments. The scheme selects the corresponding plist section for Run and Archive. ## CocoaPods alternative The published 1.0.21 tag also contains `KoardSDK.podspec`. The demo uses SPM by default; to use CocoaPods instead, remove the SPM `KoardSDK` product from the app target and its package dependency before adding this Podfile in `demos/ios`: ```ruby platform :ios, '17.0' project 'KoardDemo', 'Debug' => :debug, 'Release' => :release, 'UAT' => :debug, 'Production' => :release target 'KoardMerchantSDK-Demo' do pod 'KoardSDK', :git => 'https://github.com/koardlabs/koard-ios.git', :tag => '1.0.21' end ``` Run `pod install`, then open `KoardDemo.xcworkspace`. Use either SPM or CocoaPods for the app target so `KoardSDK` is linked once. This is a tagged Git pod; it does not require a local SDK checkout. See [Installing the SDK](installing-sdk.md) for both dependency options. ## Run the standard checkout flow Use a supported physical iPhone with the required Tap to Pay entitlement and [Sandbox Apple Account](creating-sandbox-account.md) for UAT. A simulator can display UI but cannot perform a card tap. 1. Log in with the merchant's code and PIN. 2. Open Settings and choose that merchant's active location. Its linked terminal determines payment routing. Display the choice in Settings and on the payment screen; revalidate saved choices after login or a merchant change. 3. Prepare the reader. If the account is unlinked, present an explicit action to accept the Tap to Pay terms, await `linkAccountAsync()`, then prepare again. 4. Enter the amount, select sale or preauth, and tap a test card. Persist the operation ID before calling the SDK. 5. Inspect the returned transaction. Handle partial approval, surcharge confirmation, pending, declined, and canceled outcomes before showing a receipt. Preauth authorizes a hold; capture is a separate operation. 6. Use transaction details for capture, refund, reversal, tip adjustment, and receipt delivery where supported by the merchant's processor. The [Running Payments](running-payments.md) samples follow this checkout sequence. They poll the SDK's public reader status, leave the reader event subscription to the SDK, and inspect the actual transaction returned by each operation. See [Transaction Metadata](transaction-metadata.md) and [SDK Lifecycle](sdk-lifecycle.md) for 1.0.21 features. # Installing the SDK Install the Koard Merchant SDK to enable tap-to-pay functionality in your Android application. If you're ready to start developing, see our Android SDK implementation guide. [Get started with Koard](/docs/introduction) **What you learn** In this guide, you'll learn: * How to add the Koard Merchant SDK to your Android project * How to configure Gradle dependencies * How to initialize the SDK in your application * How to install Visa Tap to Pay Ready directly from Google Play or from your app * How to verify your installation is working correctly **Prerequisites** Before you begin, ensure you have: * **Android Studio Hedgehog or newer** (required for modern Gradle support) * **JDK 21** (required for compilation) * **Android SDK 36** (minimum SDK 33 - Android 13) * **Physical Android device with NFC support** (Android 13+) — see [Supported Devices & NFC Tap Location](/docs/setting-up-the-android-sdk/supported-devices) * **Valid Koard merchant account** (configured in [Koard MMS](/docs/appendix/glossary#mms "Koard portal for merchants, terminals, locations, credentials, and API keys.")) * **Visa Tap to Pay Ready 25.06.10 or later** must be installed before enrollment or payments — choose either of the [installation options](#step-5-install-visa-tap-to-pay-ready) below **Physical Device Required**: [Tap to Pay](/docs/appendix/glossary#tap-to-pay-android "Contactless acceptance on NFC Android devices using the Visa Tap to Pay Ready kernel.") on Android requires a physical device with NFC hardware. The Android emulator does not support NFC contactless payments. **Developer Mode Workflow**: When working with Tap to Pay on Android, follow this important workflow: 1. **Enable Developer Mode** - Turn on developer mode to install and test app revisions 2. **Install/Update Your App** - Deploy your application updates 3. **Disable Developer Mode** - You MUST turn off developer mode before running tap to pay transactions 4. **Run Transactions** - Process payments with developer mode disabled Tap to Pay transactions will NOT work if developer mode is enabled. This is a security requirement from the payment processor. **Lifecycle and metadata support**: The new [SDK Lifecycle](/docs/setting-up-the-android-sdk/sdk-lifecycle) and [Transaction Metadata](/docs/setting-up-the-android-sdk/transaction-metadata) examples require SDK **1.0.7 or later**. This guide targets 1.0.7. Use Maven Central as shown below, or the supplied AAR through [Manual AAR Installation](#fallback-manual-aar-installation). The [1.0.7 demo branch](https://github.com/koardlabs/koard-android/tree/release/1.0.7/libs-maven) also includes the same signed release in its Maven mirror. ## Step 1: Add the SDK Dependency Use SDK **1.0.7**, published on **Maven Central** as `com.koard:koard-android-sdk:1.0.7` (packaging `aar`). No credentials or custom repository are required — only `mavenCentral()` in your repositories. ### 1a. Ensure Maven Central is a repository Most Android projects already resolve dependencies from Maven Central. If yours doesn't, add `mavenCentral()` to `dependencyResolutionManagement` in your **`settings.gradle.kts`**: ```kotlin dependencyResolutionManagement { repositories { google() mavenCentral() } } ``` ### 1b. Add the dependency Add the Koard SDK to your **app module's `build.gradle.kts`**: ```kotlin dependencies { implementation("com.koard:koard-android-sdk:1.0.7") } ``` ### 1c. Declare the libraries the SDK does _not_ bring in The 1.0.7 artifact is a **shaded fat AAR**. OkHttp, Okio, Retrofit, kotlinx.serialization, and Ktor are packaged _inside_ the AAR (relocated under `com.koardlabs.*`) and are **deliberately dropped from the published POM** — this isolates the SDK from whatever versions your app uses. The published POM declares only: | Dependency | Version | | ------------------------------------------------ | ------- | | `org.jetbrains.kotlin:kotlin-stdlib` | 2.2.21 | | `org.jetbrains.kotlin:kotlin-parcelize-runtime` | 2.2.21 | | `androidx.security:security-crypto` | 1.1.0 | | `com.google.android.gms:play-services-safetynet` | 18.0.1 | | `com.nimbusds:nimbus-jose-jwt` | 10.0.2 | **Nothing else resolves transitively.** AndroidX, Kotlin coroutines, and any networking or serialization library your own code uses must be declared in your app module. If you previously relied on the SDK to pull OkHttp/Retrofit/kotlinx.serialization in for you (1.0.5 and earlier shipped them as ordinary transitive dependencies), declare them directly now. ```kotlin dependencies { implementation("com.koard:koard-android-sdk:1.0.7") // Declare what YOUR code needs — the SDK no longer supplies these transitively. implementation("androidx.core:core-ktx:1.12.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") } ``` ### Fallback: Manual AAR Installation If you have been supplied the SDK as a raw `.aar` file (for example, an offline or pre-release build), you can side-load it instead of resolving from Maven Central: 1. **Create a `libs` directory** in your app module if it doesn't exist 2. **Copy the AAR file** (e.g., `koard-android-release.aar`) to the `libs/` directory 3. **Add the dependency** in your `build.gradle.kts`: ```kotlin dependencies { implementation(files("libs/koard-android-release.aar")) // A raw file dependency carries no POM, so declare everything the SDK // would otherwise pull in — plus whatever your own code needs. // The five dependencies the published POM declares: implementation("org.jetbrains.kotlin:kotlin-stdlib:2.2.21") implementation("org.jetbrains.kotlin:kotlin-parcelize-runtime:2.2.21") implementation("androidx.security:security-crypto:1.1.0") implementation("com.google.android.gms:play-services-safetynet:18.0.1") implementation("com.nimbusds:nimbus-jose-jwt:10.0.2") // Plus whatever your own code needs: implementation("androidx.core:core-ktx:1.12.0") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") } ``` Do **not** add OkHttp, Retrofit, Okio, kotlinx.serialization, or Ktor "for the SDK" — they are already shaded inside the AAR under `com.koardlabs.*`. Add them only if _your own_ code uses them; the shaded copies will not conflict with yours. ## Step 2: Configure AndroidManifest.xml Permissions and Package Visibility Add the required permissions, the `` tag, and the NFC intent filter to your `AndroidManifest.xml`: ```xml ``` The `` tag declares visibility to the Tap to Pay kernel app (`com.visa.kic.app.kernel`), which is required for the SDK to detect and communicate with the Visa kernel during tap-to-pay flows. Android 11 and later hide other installed apps unless they are declared this way, so without it enrollment fails with "Kernel App is not installed" even when the kernel is installed. See [Troubleshooting](/docs/setting-up-the-android-sdk/troubleshooting). **Where each permission comes from.** `INTERNET`, `ACCESS_NETWORK_STATE`, `NFC`, and `WAKE_LOCK` are declared in the SDK's own manifest and merged into your app, but declaring them explicitly keeps your permission surface auditable. `ACCESS_WIFI_STATE` and `REBOOT` are **required by Visa's Kernel-in-Cloud (KiC) integration spec** (§3.3.3, "mandatory minimum permissions") for apps that integrate the Tap to Pay Ready kernel — they are **not** contributed by the Koard SDK, so you must declare them yourself. The SDK also contributes `` and `android.hardware.nfc.hce`, both with `required="false"`. The `` tag and the NFC intent filter are likewise not contributed by the SDK. If your activity sets `android="portrait"`, also declare `` so Google Play does not filter out POS devices (Sunmi D3, iMin Swan 1 Pro, and similar) that do not report that feature. ### Add the NFC Tech Filter Resource Create `res/xml/nfc_tech_filter.xml` referenced by the `meta-data` above: ```xml android.nfc.tech.IsoDep ``` Confirm the app's merged manifest includes the `com.visa.kic.app.kernel` package query as a child of ``. This is an app manifest requirement; changing Gradle dependencies alone does not make the package visible. ## Step 3: Configure Build Settings ### Set Minimum SDK Version Ensure your app's minimum SDK is set to Android 13 (API level 33): ```kotlin android { compileSdk = 36 // belongs on `android { }`, NOT inside defaultConfig defaultConfig { minSdk = 33 targetSdk = 36 } } ``` ### Configure Java Compatibility Set Java version to 21: ```kotlin android { compileOptions { sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 } kotlinOptions { jvmTarget = "21" } } ``` ### Add Build Flavors (Optional) The SDK ships exactly two built-in environments — `KoardEnvironment.UAT` and `KoardEnvironment.PROD` — so mirror the demo app and define one flavor per environment: ```kotlin android { flavorDimensions += "environment" productFlavors { create("uat") { dimension = "environment" isDefault = true buildConfigField("String", "ENVIRONMENT", "\"UAT\"") applicationIdSuffix = ".uat" // UAT: https://api.uat.koard.com } create("prod") { dimension = "environment" buildConfigField("String", "ENVIRONMENT", "\"PROD\"") // Production: https://api.koard.com } } } ``` There is no `dev` environment constant. To target a non-standard backend, use `KoardEnvironment.Custom(koardApiUrl, enrollmentUrl, visaCloudPosUrl, name)` rather than adding a `dev` flavor that has nothing to map onto. ## Step 4: Initialize the SDK ### Create Application Class Start SDK initialization from `Application.onCreate()` on a worker scope. Expose initialization state to your UI and wait for it before retrieving the SDK or opening authenticated screens. Keep the main thread free while initialization runs. ```kotlin import android.app.Application import com.koardlabs.merchant.sdk.KoardMerchantSdk import com.koardlabs.merchant.sdk.domain.KoardEnvironment import com.koardlabs.merchant.sdk.domain.KoardLogLevel import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch class MyApplication : Application() { enum class SetupState { STARTING, READY, FAILED } private val sdkScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val _setupState = MutableStateFlow(SetupState.STARTING) val setupState = _setupState.asStateFlow() override fun onCreate() { super.onCreate() sdkScope.launch { try { KoardMerchantSdk.initialize( application = this@MyApplication, apiKey = "your-api-key", environment = KoardEnvironment.UAT, timeoutSeconds = 30L, logLevel = KoardLogLevel.NONE ) _setupState.value = SetupState.READY } catch (error: CancellationException) { throw error } catch (error: Exception) { _setupState.value = SetupState.FAILED } } } } ``` Render a setup indicator for `STARTING`, your login or authenticated screen for `READY`, and a setup error with a retry or restart action for `FAILED`. Apply the same readiness gate before registering NFC or refreshing reader checks. Cancel any foreground setup job when the activity pauses, so a delayed initialization cannot register an inactive activity. **Important**: * SDK initialization must run on a worker thread. Do not wrap it in `runBlocking` from `Application.onCreate()`, because that still blocks app startup even when the inner work uses `Dispatchers.IO`. * Retrieve `KoardMerchantSdk.getInstance()` only after initialization succeeds. * The `initialize()` arguments are `application`, `apiKey`, `environment`, and optional `timeoutSeconds` and `logLevel`. Authenticate the merchant separately with `login(merchantCode, merchantPin)`. * `logLevel` is configured at initialization. Use `NONE` for production builds. ### Register Application in Manifest Add your custom Application class to `AndroidManifest.xml`: ```xml ``` ## Step 5: Install Visa Tap to Pay Ready Visa Tap to Pay Ready **25.06.10 or later** must be installed on the merchant's device before enrollment or payments. The minimum version is **25.06.10**, correcting the `26.06.10` typo in earlier SDK requirements. Version **25.06.25** meets this minimum. Keep the app updated through Google Play. Choose either installation option: * **Install directly from Google Play:** the merchant opens the [Google Play listing](https://play.google.com/store/apps/details?id=com.visa.kic.app.kernel) and installs the app. * **Install from your app (optional):** offer an Install action using `sdk.installKernelApp(activity)`. Both options satisfy the installation requirement. Adding an in-app Install action is optional. ### Optional: In-App Installation After SDK initialization, you can provide an **Install** action that checks for the app and launches the install flow when needed: ```kotlin import android.app.Activity import com.koardlabs.merchant.sdk.KoardMerchantSdk import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext // Call from a coroutine when the merchant taps Install. suspend fun installTapToPayReady(activity: Activity) { withContext(Dispatchers.IO) { val sdk = KoardMerchantSdk.getInstance() if (!sdk.isKernelAppInstalled()) { sdk.installKernelApp(activity) } } } ``` Pass the current `Activity`. Both SDK methods must run on a worker thread, as shown above. `installKernelApp(activity)` opens the Google Play in-app install overlay when available and falls back to the app's store listing if the overlay cannot be opened. If neither can be opened, the SDK throws `KoardException`; show an error and let the merchant retry. The method returns after launching the install screen; it does not wait for installation to finish. If the merchant cancels or installation is still incomplete, let them retry. ### Confirm Installation Whichever option the merchant uses, call `refreshReadinessChecks()` on `Dispatchers.IO` from your activity's `onResume()`, as shown in the next step. This updates `readinessState` when the merchant returns from installation. Use `readinessState.value.kernelAppInstalled` to confirm the app is installed before enrollment, and `readinessState.value.isReadyForTransactions` to decide when to enable payments. ## Step 6: Register Your Activity for NFC Android dispatches `TECH_DISCOVERED` to whatever app claims it, so an unrelated NFC app can jump into the foreground the moment the customer presents a card. `registerActivityForNfc(this)` claims NFC foreground dispatch for your activity so it stays in front for the duration of the tap — including the receipt or animation screens that follow, while the card may still be in the field (KiC integration guide §3.4.14). Call it from `onResume()` and `unregisterActivityForNfc(this)` from `onPause()`. Both are suspend functions that must run on a worker thread. Visa marks both APIs **Implementation: Optional** (§3.3.15.1, §3.3.15.2) — a tap can complete without them — but skipping them leaves the tap exposed to being interrupted by another app, so Koard recommends wiring them up on every activity that can host a tap. ```kotlin import android.util.Log import android.widget.Toast import androidx.activity.ComponentActivity import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import com.koardlabs.merchant.sdk.KoardMerchantSdk import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext class MainActivity : ComponentActivity() { private val nfcMutex = Mutex() private var foregroundSetup: Job? = null override fun onResume() { super.onResume() foregroundSetup?.cancel() foregroundSetup = lifecycleScope.launch { val state = (application as MyApplication).setupState.first { it != MyApplication.SetupState.STARTING } if (state != MyApplication.SetupState.READY || !lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)) return@launch try { withContext(Dispatchers.IO) { nfcMutex.withLock { KoardMerchantSdk.getInstance().registerActivityForNfc(this@MainActivity) } KoardMerchantSdk.getInstance().refreshReadinessChecks() } } catch (error: CancellationException) { throw error } catch (error: Exception) { Toast.makeText(this@MainActivity, "Tap to Pay setup failed. Reopen the screen to retry.", Toast.LENGTH_LONG).show() } } } override fun onPause() { foregroundSetup?.cancel() lifecycleScope.launch(Dispatchers.IO) { if ((application as MyApplication).setupState.value == MyApplication.SetupState.READY) { try { nfcMutex.withLock { KoardMerchantSdk.getInstance().unregisterActivityForNfc(this@MainActivity) } } catch (error: CancellationException) { throw error } catch (error: Exception) { Log.w("TapToPaySetup", "NFC foreground cleanup failed", error) } } } super.onPause() } } ``` **Wait for setup and cancel paused work**: wait for initialization before accessing the SDK, cancel the foreground job on pause, and guard NFC registration and unregistration with a mutex so rapid resume/pause cycles cannot interleave them. Handle SDK errors in your app and show the merchant a retry action. Also add the `TECH_DISCOVERED` intent filter and the `@xml/nfc_tech_filter` tech-list to the same activity in your manifest — per KiC §3.4.14 the registration APIs are meant to be used alongside that manifest declaration, and it is not contributed by the Koard SDK. `unregisterActivityForNfc()` is safe to call from `onPause()` even during a tap: the SDK detects an in-flight tap and skips the unregister. This matters because the Visa kernel foregrounds its own secure Online-PIN keypad, which pauses your activity — tearing down the registration there would abort PIN entry and decline the sale. ## Step 7: Authenticate Merchant After SDK initialization, authenticate the merchant using their credentials: ```kotlin import android.os.Bundle import androidx.activity.ComponentActivity import androidx.lifecycle.lifecycleScope import com.koardlabs.merchant.sdk.KoardMerchantSdk import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch class LoginActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) lifecycleScope.launch(Dispatchers.IO) { try { val sdk = KoardMerchantSdk.getInstance() // Login with merchant credentials val success = sdk.login( merchantCode = "your-merchant-code", merchantPin = "your-merchant-pin" ) if (success) { println("Merchant authenticated successfully") } else { println("Authentication failed") } } catch (e: CancellationException) { throw e } catch (e: Exception) { println("Error: ${e.message}") } } } } ``` The `login()` method authenticates the merchant and retrieves an access token. This must be done before processing transactions. `login()` returns `false` rather than throwing when the credentials are rejected. If your integration has already resolved the merchant identity into a single opaque string (a QR scan, an SSO callback, or a server-issued provisioning token), use the `login(alias: String): Boolean` overload instead. It hits the same login route and yields the same session token; the alias itself is never stored. Call `logout()` to end the session. Logout clears the session token and the active location but **preserves enrollment**, so the same merchant can log back in without re-enrolling. Switching the device to a _different_ merchant requires an explicit `unenrollDevice()` first. ### Test Basic Import Add this import statement to verify the SDK is accessible: ```kotlin import com.koardlabs.merchant.sdk.KoardMerchantSdk import com.koardlabs.merchant.sdk.domain.KoardEnvironment import com.koardlabs.merchant.sdk.domain.KoardLocation import com.koardlabs.merchant.sdk.domain.exception.KoardException ``` If the imports succeed without errors, your installation is working correctly. ## Step 8: Select a Location and Enroll the Device **Both of these steps are mandatory.** Enrollment is **not** automatic — nothing happens as a side effect of `login()`. Until the device has an active location _and_ is enrolled, `sale()`, `preauth()`, `completePartialAuth()`, and `refundEmv()` throw `KoardException` with `error.errorType` of `KoardErrorType.NotReady.NoActiveLocation` or `KoardErrorType.NotReady.NotEnrolled`. The order is fixed: authenticate, choose a location, then enroll. ```kotlin import com.koardlabs.merchant.sdk.KoardMerchantSdk import com.koardlabs.merchant.sdk.domain.exception.KoardException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext suspend fun setUpDevice(selectedLocationId: String) = withContext(Dispatchers.IO) { val sdk = KoardMerchantSdk.getInstance() // 1. Revalidate the operator's app-settings selection after login. val merchant = sdk.getMerchantAccount().getOrElse { error -> println("Could not load merchant: ${error.message}") return@withContext } val locations = sdk.getLocations().getOrElse { error -> println("Could not load locations: ${error.message}") return@withContext } val location = locations.firstOrNull { it.id == selectedLocationId && it.accountId == merchant.id } if (location == null || location.terminalId.isNullOrBlank()) { println("Choose a location belonging to this merchant with a linked terminal") return@withContext } try { // 2. Set the active location. Backend-authoritative: it re-binds the // terminal on the backend and only commits locally if that succeeds. sdk.setActiveLocation(location.id) // 3. Enroll the device. Returns a String status; throws on failure. val status = sdk.enrollDevice() println("Enrollment status: $status") } catch (e: KoardException) { println("Setup failed: ${e.error.shortMessage} (${e.error.errorType})") } } ``` ### `setActiveLocation(locationId)` * Suspend, worker thread only, returns `Unit` and **throws** on failure — it no longer silently no-ops. * On an unenrolled device it simply records the selection and flips readiness to `hasActiveLocation = true`. It does **not** enroll for you; you still have to call `enrollDevice()`. * If the device is **already enrolled** and you switch to a _different_ location, the SDK re-binds the terminal on the backend first and commits the new location locally only if the backend approves. A failed re-bind throws and leaves the previous location intact. It then pushes the new location's terminal/CVM profile down to the kernel. * Switching locations on an enrolled device **throws `InvalidRequest` when developer mode is on** — the Visa kernel will not accept a new profile in that state, so the SDK refuses the switch rather than half-applying it. * During the switch, readiness reports `NotReady.Preparing`, so a tap started mid-switch is rejected instead of running against a terminal that is still moving. Observe the resolved location reactively rather than caching your own copy — a private cache survives logout and goes stale, which blocks the next enrollment with "no active location set": ```kotlin sdk.activeLocation.collect { location -> // StateFlow updateUi(location?.name) } // On a cold start, resolve the persisted id into a full object once: sdk.refreshActiveLocation() // suspend, returns KoardLocation? sdk.activeLocationId // String? — the raw persisted id ``` ### `enrollDevice()` `suspend fun enrollDevice(): String` — worker thread only. It returns a status **String** and throws `KoardException` when it cannot proceed. Before calling it, the SDK refreshes its readiness checks and rejects the call when: * **Developer mode is enabled** — `InvalidRequest`, "Disable developer mode before enrolling device." * **The device is already enrolled** — `InvalidRequest`, "Clear enrollment data before enrolling again." Call `clearEnrollmentState()` (local only) or `unenrollDevice()` (clears the enrollment data held by the Tap to Pay Ready app, then clears local state) first. Note that `unenrollDevice()` does **not** fully deprovision the device on Visa's backend — see [Troubleshooting](/docs/setting-up-the-android-sdk/troubleshooting#tamperdetected-on-every-sale-after-a-failed-enrollment). * **No active location is set** — enrollment requires one, which is why Step 7 runs in this order. Because it is a user-visible, failure-prone operation, surface it as an explicit action in your UI (the demo app renders an **Enroll Device** button whenever `readinessState.enrollmentState` is not `Enrolled`) rather than firing it silently on login. Track progress through `sdk.readinessState`, whose `enrollmentState` moves `NotEnrolled` → `Enrolling` → `Enrolled` (or `Failed`). Use `readiness.notReadyReason()` to get the typed blocking reason without attempting a transaction. ## Step 9: Transaction Processing With installation complete you can now run Tap to Pay sessions. Keep these guardrails in mind: * `sdk.sale(...)`, `sdk.preauth(...)`, `sdk.completePartialAuth(...)`, `sdk.refundEmv(...)`, and `sdk.prepare()` are the APIs that drive a reader session, so they return a cold `Flow` that drives the reader UI. Other calls reach the thin client too (`isKernelAppInstalled()`, `checkKiCEligibility()`, `getTransactionConfig()`, `cancelTransaction()`, `resetKernelService()`, `unenrollDevice()`, the NFC registration pair), but they are one-shot and emit no reader events. * Reference-based `sdk.refund(transactionId, ...)` returns `Result` without a tap. The Activity-based `sdk.refund(..., withTap = true)` returns a reader Flow; `refundEmv(...)` is also supported as a tap-refund alias. * Every other payment action (capture, reverse, [incremental auth](/docs/appendix/glossary#incremental-auth "Increase an existing authorization hold by a delta amount."), receipts, tip adjustment) is a suspend function that returns a `Result` and never emits reader events. * The SDK enforces readiness (`sdk.readinessState.value.isReadyForTransactions`) and blocks taps if the required tap-to-pay dependency is missing, developer mode is on, the device is not enrolled, or no active location is set. Rather than duplicating code here, the dedicated [Running Payments](/docs/setting-up-the-android-sdk/running-payments) guide shows: * How the demo’s `MainScreenViewModel` builds `PaymentBreakdown`, collects the Flow, and updates UI state * Mapping `KoardReaderStatus` + reader status codes to your own UX * Handling [surcharge](/docs/appendix/glossary#surcharge "Fee on eligible credit-card transactions to offset processing costs (not debit).") confirmation, canceling the reader, and orchestrating post-reader calls (capture/refund/reverse/incremental/[tip adjust](/docs/appendix/glossary#tip-adjust "Update gratuity before settlement; does not recompute surcharge.")/receipt) Use that guide as the canonical reference when wiring payments into your application. ## Minification, ProGuard, and R8 SDK 1.0.7 includes the consumer rules required for minified release builds. Keep R8 enabled if your app uses it; Gradle merges the AAR's rules automatically. The fix preserves Retrofit coroutine/response generic signatures and relocates the bundled dependencies' rules to their `com.koardlabs.*` package names. On 1.0.6, missing rules could cause `java.lang.Class cannot be cast to java.lang.reflect.ParameterizedType` during login before an HTTP request was sent. Upgrade to 1.0.7 and clean-rebuild your app and any Flutter/React Native/JVM wrapper that calls the SDK. Kotlin source callers can omit optional metadata, but Java callers must pass the new argument (`null` when unused); previously compiled payment call sites must be rebuilt. Do not add keep rules for unshaded `retrofit2.*` or `okhttp3.*` to fix the SDK's shaded copies. Avoid repackaging the AAR in a way that drops its consumer rules or embedded `META-INF` rule resources. SDK logging can remain `KoardLogLevel.NONE`; logging verbosity is not a substitute for correct R8 rules. Validate the actual minified release build on a supported physical device. For a remaining failure, preserve the full native exception/cause chain and the matching R8 `mapping.txt`, without logging merchant PINs, API keys, or session tokens. ## Troubleshooting ### Common Installation Issues **SDK Not Initialized Error** ```plaintext IllegalStateException: Instance is null. Did you forget to call initialize? ``` **Solution**: Ensure `KoardMerchantSdk.initialize()` is called in `Application.onCreate()` on a worker thread before accessing `getInstance()`. **Thread Enforcement Error** **Solution**: All SDK operations must be called from a worker thread. Use `Dispatchers.IO`: ```kotlin lifecycleScope.launch(Dispatchers.IO) { // SDK operations here } ``` **Dependency Conflicts** **Solution**: Ensure you're using compatible versions of AndroidX and Kotlin libraries. Check for dependency conflicts with: ```bash ./gradlew app:dependencies ``` **Build Errors** **Solution**: * Verify you're using JDK 21 * Ensure minimum SDK is set to 33 (Android 13) * Clean and rebuild: `./gradlew clean build` **NFC Transactions Failing** **Solution**: * **Disable developer mode before running transactions** - This is required for security * Developer mode blocks tap to pay functionality * Workflow: Enable dev mode → Install app → Disable dev mode → Run transactions * Go to **Settings > System > Developer Options** and toggle OFF * Restart device after disabling developer mode ### Verification Checklist * [ ] Android Studio Hedgehog or newer installed * [ ] JDK 21 configured * [ ] Minimum SDK set to 33 (Android 13) * [ ] `mavenCentral()` present in repositories * [ ] SDK dependency added to build.gradle.kts * [ ] Application class created and registered * [ ] SDK.initialize() called on worker thread * [ ] Required permissions added to manifest (INTERNET, ACCESS\_NETWORK\_STATE, ACCESS\_WIFI\_STATE, REBOOT, NFC, WAKE\_LOCK) * [ ] `` tag added with `com.visa.kic.app.kernel` * [ ] NFC `TECH_DISCOVERED` intent filter and `@xml/nfc_tech_filter` meta-data added to your tap activity * [ ] `registerActivityForNfc()` / `unregisterActivityForNfc()` wired into `onResume()` / `onPause()` * [ ] `setActiveLocation(locationId)` called after login * [ ] `enrollDevice()` exposed as an explicit user action and completed successfully * [ ] Project builds without errors * [ ] Import statements work correctly * [ ] Visa Tap to Pay Ready 25.06.10 or later installed directly from Google Play or through the optional in-app flow * [ ] `refreshReadinessChecks()` runs on resume, and `readinessState.value.kernelAppInstalled` is `true` before enrollment * [ ] Physical device with NFC available for testing * [ ] Aware of developer mode workflow (enable → install → disable → run transactions) ## Next Steps Once the SDK is installed and configured: * [Run the Demo App](/docs/setting-up-the-android-sdk/demo) - Test SDK functionality with the demo application * [Supported Devices & NFC Tap Location](/docs/setting-up-the-android-sdk/supported-devices) - Compatible devices, requirements, and where to tap * [Running Payments](/docs/setting-up-the-android-sdk/running-payments) - Implement tap-to-pay flows, surcharging, and post-reader actions * [SDK Response Codes](/docs/setting-up-the-android-sdk/sdk-response-codes) - Understand transaction outcomes, display messages, and error handling * [Troubleshooting](/docs/setting-up-the-android-sdk/troubleshooting) - Fix enrollment failures and taps that cancel instantly * [Understand Payment Lifecycle](/docs/payments/payment-lifecycle) - Learn about the complete payment flow ## See also This wraps up the SDK installation. See the links below for next steps in your integration: * [Demo App Setup](/docs/setting-up-the-android-sdk/demo) - Run the demo application * [SDK Response Codes](/docs/setting-up-the-android-sdk/sdk-response-codes) - Transaction outcomes, error codes, and display messages * [Payment Lifecycle](/docs/payments/payment-lifecycle) - Complete payment flow guide * [Supported Devices & NFC Tap Location](/docs/setting-up-the-android-sdk/supported-devices) - Device compatibility and tap guidance * [Troubleshooting](/docs/setting-up-the-android-sdk/troubleshooting) - Device-side fixes for enrollment and tap failures ## Best Practices * **[Idempotency](/docs/appendix/glossary#idempotency "Safe retry pattern using event_id so network failures do not double-charge.")**: Persist a unique `eventId` (UUID4) for each operation that exposes the argument. Android 1.0.7 capture and adjustment do not; reconcile uncertain outcomes before any further attempt. * **Threading**: Always hop to `Dispatchers.IO` before calling any SDK method; return results to the main thread only after the call completes. * **Environment Flavors**: Mirror the demo’s `uat`/`prod` flavors so each build points at the correct Koard environment and credential set. Use `KoardEnvironment.Custom(...)` for anything else. * **Logging**: The SDK writes to Logcat under the fixed tag **`KoardSDK`**. Set verbosity once via `initialize(..., logLevel = KoardLogLevel.VERBOSE)` — there is no runtime setter, and the SDK no longer depends on Timber. Secrets ([API key](/docs/appendix/glossary#api-key "Secret header credential for REST calls; v5 keys use scoped permissions.") and token headers, device certificates, enrollment key material) are never logged at any level. * **Device Re-provisioning**: When wiping devices or reinstalling the tap-to-pay kernel app, call `refreshDeviceCertificates()` to regenerate keys and rerun enrollment. # Running Payments on Android Learn how to drive live [Tap to Pay](/docs/appendix/glossary#tap-to-pay-android "Contactless acceptance on NFC Android devices using the Visa Tap to Pay Ready kernel.") on Android sessions with the Koard SDK and how to handle every follow-up transaction step. **What you learn** * Which APIs emit reader events, including the card-present `refund()` overload * How to validate SDK readiness before launching the thin client * Handling `Flow` to power custom UIs * Running Practice Mode taps to validate the online-PIN flow * Managing [surcharge](/docs/appendix/glossary#surcharge "Fee on eligible credit-card transactions to offset processing costs (not debit).") confirmation, tip/tax/surcharge breakdowns, and telemetry * Post-reader operations such as capture, refund, EMV refund, reverse, [incremental auth](/docs/appendix/glossary#incremental-auth "Increase an existing authorization hold by a delta amount."), and tip adjustments For custom order/refund references, see [Transaction Metadata](/docs/setting-up-the-android-sdk/transaction-metadata). For `deinit()`, initialization status, and signing in after a reset, see [SDK Lifecycle and Deinitialization](/docs/setting-up-the-android-sdk/sdk-lifecycle). These examples target the published **Android SDK 1.0.7**. Amount arguments are `Int` minor currency units. [Install this version](/docs/setting-up-the-android-sdk/installing-the-sdk) before using them. ## Active Location and Terminal Routing **Your app must provide a way to select the active location in settings.** Authenticate the merchant, load that merchant's available locations, and let the operator select the location where the device is taking payments. The selected `location_id` resolves the linked `terminal_id`, whose processor configuration, MID, and TID determine payment routing for that merchant. Show the active location in settings and on the payment screen. Select it before reader preparation or any payment. If the operator changes merchants, clear the previous selection and reload the new merchant's locations. If you remember a choice between sessions, validate that it still belongs to the authenticated merchant and still has a usable linked terminal. Do not silently reuse another merchant's location or select an arbitrary terminal. * **iOS:** call `KoardMerchantSDK.shared.setActiveLocationID(location.id)`, then follow the account-linking and reader preparation flow. * **Android:** call the suspending `sdk.setActiveLocation(location.id)`, then `sdk.enrollDevice()` and the reader preparation flow. Block payment initiation until location selection and required readiness checks have completed. If a location's terminal changes, reload the configuration and complete the SDK's readiness flow before taking another payment. ## Step 1: Check Visa Tap to Pay Ready Installation Before enrollment or payments, call `sdk.refreshReadinessChecks()` on a worker thread and inspect `sdk.readinessState.value.kernelAppInstalled`. If it is `false`, the merchant can install directly from Google Play, or your app can optionally offer an Install action using `sdk.installKernelApp(activity)`. Refresh again in `onResume()` when the merchant returns. See [Install Visa Tap to Pay Ready](/docs/setting-up-the-android-sdk/installing-the-sdk#step-5-install-visa-tap-to-pay-ready) for both options and the activity example. ## Prerequisites Before starting any NFC session: * Initialize the SDK in `Application.onCreate()` and authenticate the merchant * Call `setActiveLocation(locationId)` and then `enrollDevice()` — both are mandatory, and neither happens automatically. See [Installing the SDK](/docs/setting-up-the-android-sdk/installing-the-sdk) * Register the hosting activity for NFC in `onResume()` / `onPause()` * Disable developer mode on the device (reader emits `developerModeEnabled` if it’s left on) * Observe `KoardMerchantSdk.getInstance().readinessState` and block the UI unless `isReadyForTransactions` is `true` ```kotlin val sdk = KoardMerchantSdk.getInstance() val readiness = sdk.readinessState.value if (!readiness.isReadyForTransactions) { Log.w("Checkout", "SDK not ready: ${readiness.getStatusMessage()}") return } ``` ## Launching a Sale Session `sdk.sale`, `sdk.preauth`, `sdk.completePartialAuth`, and the Activity-based `sdk.refund(..., withTap = true)` (also available as `sdk.refundEmv`) drive a reader session, so they return cold `Flow` streams. `sdk.prepare` also returns a `Flow`, but it is a warm-up that emits progress toward `Done` — it never runs a transaction. Create the flow on a worker thread and apply `flowOn(Dispatchers.IO)` so its reader work also runs there. Collect from your UI coroutine to update the screen on Main. (Other calls reach the thin client too — `isKernelAppInstalled`, `checkKiCEligibility`, `getTransactionConfig`, `cancelTransaction`, `resetKernelService`, `unenrollDevice`, and the NFC registration pair — but they are one-shot and do not emit reader events.) ```kotlin suspend fun sale( activity: Activity, amount: Int, // amount in cents breakdown: PaymentBreakdown? = null, buttonProperties: List? = null, currency: String = "USD", eventId: String? = null, tapTimeoutMs: Long? = null, // auto-cancel the tap after this many ms transactionType: String = "Payment", // "TestWithPin" for Practice Mode metadata: JSONObject? = null // app-defined JSON context ): Flow ``` `metadata` is an optional `org.json.JSONObject`; see [Transaction Metadata](/docs/setting-up-the-android-sdk/transaction-metadata) for examples and how to retrieve it. Pass `buttonProperties` to customize the on-screen reader buttons, and `tapTimeoutMs` to automatically cancel the reader session if no card is presented in time. `currency` must be one of the currency codes the kernel reports for this device. Per KiC §3.4.8 the transaction's `currencyCode` "can be one of the currencies that comes out of the `getTransactionConfig` api", so if you support anything beyond a single fixed currency, read the live list from `sdk.getTransactionConfig()` (a suspend call returning `KoardTransactionConfig?`, wrapping KiC §3.4.16) rather than hard-coding it. The same object carries the supported `transactionTypes`, `supportedButtons`, `supportedCountries`, and `supportedLanguages`. Visa marks `getTransactionConfig` **Implementation: Conditional** — it is there to be called on demand when you need the latest config. **`preauth` no longer matches `sale`'s signature.** `transactionType` exists on `sale()` only. In 1.0.7, both methods accept the first seven parameters (`activity` through `tapTimeoutMs`) and optional trailing `metadata`; `sale()` places `transactionType` before `metadata`. Amounts remain `Int` values in minor currency units. **Readiness is enforced by throwing, not by emitting.** `sale()`, `preauth()`, `completePartialAuth()`, and `refundEmv()` refresh their readiness checks and **throw `KoardException`** before returning a Flow when the SDK is not ready. Inspect `e.error.errorType`: it is a `KoardErrorType.NotReady.*` value such as `KernelAppNotInstalled`, `DeveloperModeEnabled`, `NotAuthenticated`, `NotEnrolled`, `NoActiveLocation`, `ReaderNotStarted`, or `Preparing`. Retry on `Preparing`; prompt the operator on the rest. Wrap the call in `try/catch`, not just the collection. ### Practice Mode (`TestWithPin`) `transactionType` is the KiC transaction type sent to the kernel: | Value | Behavior | | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `"Payment"` (default) | A real sale. The kernel picks the CVM from the card and [terminal profile](/docs/appendix/glossary#terminal-profile "Reusable terminal settings for tipping, surcharge, receipts, timeouts, and UI.") — usually signature or no-CVM, never a guaranteed PIN | | `"TestWithPin"` | **Practice Mode** (KiC Annex A-2). Forces the PIN-entry UI so you can validate the online-PIN flow without routing to the acquirer | ```kotlin sdk.sale( activity = activity, amount = 1000, transactionType = "TestWithPin" // Practice Mode — exercises the PIN keypad ).collect { response -> handleTransactionEvent(response) } ``` Practice Mode is only valid when the device enrollment's supported transaction types include `TestWithPin`. Read the enrollment's supported types from `sdk.getEnrollmentDetails()?.transactionTypes` before offering it in your UI. ```kotlin import android.app.Activity import com.koardlabs.merchant.sdk.KoardMerchantSdk import com.koardlabs.merchant.sdk.domain.* import com.koardlabs.merchant.sdk.domain.exception.KoardException import kotlinx.coroutines.* import kotlinx.coroutines.flow.* fun startSale(activity: Activity, amountInCents: Int, eventId: String) { // lifecycleScope runs on Main. eventId was saved with the order before this call. lifecycleScope.launch { var receivedTerminalEvent = false try { val flow = withContext(Dispatchers.IO) { KoardMerchantSdk.getInstance().sale( activity = activity, amount = amountInCents, breakdown = PaymentBreakdown(subtotal = amountInCents), currency = "USD", eventId = eventId ) }.flowOn(Dispatchers.IO) flow.collect { response -> if (handleTransactionEvent(response)) receivedTerminalEvent = true } if (!receivedTerminalEvent) { showUnresolved("Reader stopped without a final result; reconcile the saved operation") } } catch (e: CancellationException) { throw e // Preserve coroutine cancellation during navigation. } catch (e: KoardException) { // Preparing is a readiness rejection before the SDK returns a Flow. // Only retry that pre-flight case; never replay a submitted charge on a timeout. when (e.error.errorType) { is KoardErrorType.NotReady.Preparing -> scheduleRetry { startSale(activity, amountInCents, eventId) } else -> showError(message = e.error.shortMessage, errorType = e.error.errorType) } } catch (e: Exception) { showUnresolved("Payment outcome unknown; reconcile the saved operation") } } } // true means this reader session delivered a terminal event, not that payment succeeded. private fun handleTransactionEvent(response: KoardTransactionResponse): Boolean { when (response.actionStatus) { KoardTransactionActionStatus.OnProgress -> { showStatus(response.readerStatus.toString(), response.displayMessage) return false } KoardTransactionActionStatus.OnFailure -> { val canceled = response.finalStatus == KoardTransactionFinalStatus.Abort || response.readerStatus == KoardReaderStatus.readCancelled val retryableRead = response.statusCode == null || response.statusCode in setOf(12, 42, 53) if (!canceled && retryableRead && response.finalStatus == null) { // Keep collecting the SAME flow; this asks for another tap, not a new sale. showStatus(response.readerStatus.toString(), response.displayMessage ?: "Please tap again") return false } if (canceled) showAborted("Transaction canceled") else showError(code = response.statusCode, message = response.displayMessage ?: "Transaction failed", finalStatus = response.finalStatus) response.transaction?.let { reconcileTransaction(it) } } KoardTransactionActionStatus.OnComplete -> { val transaction = response.transaction when { response.finalStatus == KoardTransactionFinalStatus.Abort || transaction?.status == KoardTransactionStatus.CANCELED -> showAborted(response.displayMessage ?: "Transaction canceled") response.finalStatus == KoardTransactionFinalStatus.Decline || transaction?.status == KoardTransactionStatus.DECLINED -> showDeclined(response.displayMessage ?: "Transaction declined") response.finalStatus == KoardTransactionFinalStatus.Failure || transaction?.status == KoardTransactionStatus.ERROR -> showUnresolved(response.displayMessage ?: "Transaction failed; check its outcome") transaction == null -> showUnresolved("Missing transaction result") transaction.status == KoardTransactionStatus.SURCHARGE_PENDING -> requestSurchargeConfirmation(transaction) transaction.statusReason == "partial_approval" -> requestPartialApproval(transaction) transaction.status == KoardTransactionStatus.PENDING -> reconcileTransaction(transaction) transaction.status in setOf(KoardTransactionStatus.AUTHORIZED, KoardTransactionStatus.CAPTURED, KoardTransactionStatus.SETTLED, KoardTransactionStatus.REFUNDED, KoardTransactionStatus.REVERSED) -> showReceipt(transaction) else -> reconcileTransaction(transaction) } } null -> showUnresolved("Missing action status; reconcile the outcome") } return true } ``` The UI helpers above are application callbacks. Classify the returned transaction even when `finalStatus` is null; the standard demo also uses transaction status to handle backend-operation results. An authorized preauth is a hold, so label its receipt accordingly. `requestSurchargeConfirmation` must disclose the returned surcharge and final total, submit the cardholder decision, and inspect the returned transaction. `requestPartialApproval` offers a decision on the partial amount or a second-card payment for the remainder. Pending and unresolved outcomes retain the saved IDs for [outcome recovery](/docs/payments/idempotency), without automatically resubmitting. Persist a separate ID for each operation that exposes `eventId`. ### Event payloads Every emitted `KoardTransactionResponse` contains the data you need to build a bespoke reader UI: * **`readerStatus`** – a `KoardReaderStatus` enum value: `preparing`, `readyForTap`, `cardDetected`, `pinEntryRequested`, `pinEntryCompleted`, `readCompleted`, `readNotCompleted`, `readCancelled`, `readRetry`, `processing`, `complete`, `developerModeEnabled`, or `unknown` * **`displayMessage`** – Reader-provided instructions ("Present card", "Processing", etc.) * **`statusCode`** / **`statusCodeDescription`** – numeric status (and its description) for advanced troubleshooting * **`actionStatus`** – `OnProgress`, `OnFailure`, or `OnComplete` (these are the only three action statuses) * **`finalStatus`** – `Approve`, `Decline`, `Abort`, `Failure`, `AltService`, or `Unknown` after reader completion * **`transaction`** – populated on completion; includes IDs, surcharge state, and breakdown so you can print receipts or prompt for confirmation **Surcharge confirmation is signaled on the transaction, not the action status.** There is no `OnConfirmSurcharge` action status. When the completion event carries a transaction whose `transaction.status == KoardTransactionStatus.SURCHARGE_PENDING`, prompt the customer and call `sdk.confirm(transactionId, confirm = true/false)` with their decision. For a complete reference of every `finalStatus`, `statusCode`, display message ID, and error scenario the SDK can return, see the [SDK Response Codes](/docs/setting-up-the-android-sdk/sdk-response-codes) guide. The demo’s `MainScreenViewModel` (see `src/main/java/com/koard/android/ui/MainScreenViewModel.kt`) shows a complete Compose-based implementation that you can adapt for your UI stack. ### Preauthorization Call `sdk.preauth(...)` when you need a hold + later capture. It takes the same parameters as `sale` except `transactionType` (Practice Mode is sale-only), the Flow emits the same events, and `response.transaction?.transactionId` is the ID you’ll pass into capture/refund APIs later. ### Completing a partial approval When an authorization is only partially approved (the issuer approved less than the requested amount), call `sdk.completePartialAuth(...)` to authorize the remaining amount on another card. The customer must tap that card in a new reader session. It returns a `Flow` just like `sale`/`preauth`; pass the original transaction ID and the remaining amount, with a separately persisted operation ID: ```kotlin suspend fun completePartialAuth( activity: Activity, transactionId: String, amount: Int, breakdown: PaymentBreakdown? = null, buttonProperties: List? = null, currency: String = "USD", eventId: String? = null, tapTimeoutMs: Long? = null ): Flow ``` ### Warming up the reader `sdk.prepare()` is a **latency optimization, not a precondition for tapping.** Visa marks the pre-transaction warm-up API **Implementation: Optional** (KiC integration guide §3.4.7, "Preparing the SDK for Point-of-Sale Transactions"), and `startTransaction` (§3.4.11) lists no warm-up among its preconditions. What warming up buys you is timing: §3.4.7 describes the session-establishment and device-attestation work as happening "before startTransaction" either way, and frames the API as a fix for "the delay seen if the startTransaction is called for the first time after app restart". Calling it moves that cost off the critical user path. The one precondition §3.4.7 does state is that **the device must already be enrolled** before you warm up, so call `prepare()` after `enrollDevice()` has succeeded, not before. Call it early (for example in `Application.onCreate`, once enrollment is confirmed). It returns a `Flow` that emits progress (`Called` → `AuthenticationInProgress` → `AttestationInProgress` → `GettingConfigurations` → `ParsingConfigurations` → `Done`); key off the `Done` emission and treat any error variant as "retry on next user action". Nothing else in your flow needs to block on it — a fire-and-forget collect on a worker scope is a valid usage pattern, since the only effect is the warm-up. ```kotlin // collect() is a suspend function — call it from a coroutine on a worker scope. applicationScope.launch(Dispatchers.IO) { sdk.prepare().collect { response -> if (response.status is KoardPrepareStatus.Done) { Log.i("Checkout", "Reader is warmed up") } } } ``` ## Post-Reader Operations All backend-only operations are suspend functions that return a `Result`, so they never emit reader events and can run from any worker thread. The payload varies by call: `capture`, `reverse`, `refund`, `adjust`, and `getTransaction` return `Result`; `getTransactions` returns `Result` (which carries the paging fields); and `sendReceipt` returns `Result`. `cancelTransaction()` and `resetKernelService()` also return a `Result` (`Result`), but they are **not** backend calls — they talk to the Tap to Pay Ready app over IPC to abort a tap or release the service connection. See [Canceling the reader session](#canceling-the-reader-session). ### Capture ```kotlin suspend fun captureTransaction(transactionId: String, amountOverride: Int? = null): KoardTransaction = withContext(Dispatchers.IO) { KoardMerchantSdk.getInstance().capture(transactionId = transactionId, amount = amountOverride) .getOrThrow() } ``` ### Incremental authorization ```kotlin suspend fun incrementalAuth(transactionId: String, additionalAmountCents: Int, eventId: String): KoardTransaction = withContext(Dispatchers.IO) { KoardMerchantSdk.getInstance().incrementalAuth(transactionId, additionalAmountCents, eventId) .getOrThrow() } ``` ### Reverse / void ```kotlin suspend fun reverseTransaction(transactionId: String, eventId: String, amountCents: Int? = null): KoardTransaction = withContext(Dispatchers.IO) { KoardMerchantSdk.getInstance().reverse(transactionId, amountCents, eventId).getOrThrow() } ``` ### Refund (reference-based) The overload without an Activity returns `Result` and uses the original transaction reference, with no new tap: ```kotlin suspend fun refundTransaction(transactionId: String, eventId: String, amount: Int? = null): KoardTransaction = withContext(Dispatchers.IO) { KoardMerchantSdk.getInstance().refund( transactionId = transactionId, amount = amount, // null requests a full refund eventId = eventId, metadata = org.json.JSONObject().put("reason", "customer_return") ).getOrThrow() } ``` `sdk.refundTransaction(transactionId, amount, eventId)` is an older reference-based alias. It has no metadata argument; prefer `refund(...)` in new code. ### EMV refund (tap required) Use the Activity-based `refund(..., withTap = true)` overload, as in the standard checkout demo. This returns a `Flow` and requires a refund amount. `refundEmv(...)` is also supported in 1.0.7 as a tap-refund alias. ```kotlin suspend fun refundWithTap(activity: Activity, transactionId: String, amountInCents: Int, eventId: String) { // Invoke from a Main/UI coroutine. Handle thrown readiness errors as in startSale. val flow = withContext(Dispatchers.IO) { KoardMerchantSdk.getInstance().refund( activity = activity, transactionId = transactionId, amount = amountInCents, withTap = true, currency = "USD", eventId = eventId, metadata = org.json.JSONObject().put("reason", "customer_return") ) }.flowOn(Dispatchers.IO) var receivedTerminalEvent = false flow.collect { response -> if (handleTransactionEvent(response)) receivedTerminalEvent = true } if (!receivedTerminalEvent) showUnresolved("Refund outcome unknown; reconcile the saved operation") } ``` The tap overload and `refundEmv()` throw typed readiness errors before returning a Flow and have no `tapTimeoutMs` argument. Both accept optional `metadata`. ### Tip adjustments ```kotlin suspend fun adjustTip(transactionId: String, newTipCents: Int): KoardTransaction = withContext(Dispatchers.IO) { KoardMerchantSdk.getInstance().adjust( transactionId = transactionId, type = AmountType.FIXED, amount = newTipCents ).getOrThrow() } ``` ### Confirming a surcharge For a transaction whose status is `SURCHARGE_PENDING`, pass the cardholder's decision to this backend operation. Use `accepted = false` to reject the surcharge. `cancelTransaction()` and `resetKernelService()` only operate on the reader connection and do not replace this call. See [pending amounts and expiry](/docs/payments/surcharging#pending-amounts-rejection-and-expiry) for the 120-second confirmation window and cancellation outcomes. ```kotlin suspend fun confirmSurcharge(transactionId: String, accepted: Boolean, eventId: String): KoardTransaction = withContext(Dispatchers.IO) { KoardMerchantSdk.getInstance().confirm( transactionId = transactionId, confirm = accepted, eventId = eventId ).getOrThrow() } ``` These helpers unwrap `Result` with `getOrThrow()` and also propagate SDK exceptions. Catch both at the UI operation boundary, preserving `CancellationException`. Inspect the returned transaction status and amounts before showing success. Pending and partial outcomes need their own handling. SDK 1.0.7 does **not** expose `eventId` on `capture` or `adjust`; reconcile the transaction before any further attempt rather than treating those operations as safely replayable. ### Canceling the reader session There are two teardown paths. They do different things — one aborts a tap in flight, the other releases the IPC connection to the Tap to Pay Ready app. | Call | Use for | Effect | | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `sdk.cancelTransaction()` | Programmatic cancel, tap timeouts | Calls KiC `cancelTransaction()`, which aborts the current tap and emits a cancel terminal through the active flow, then clears the SDK's transaction guard. It does **not** call `unbindKernelService()`, so the SDK-connector's binding to the Tap to Pay Ready app is left in place | | `sdk.resetKernelService()` | Navigating away from the transaction screen (including after a completed transaction, before the receipt screen), and recovery: the kernel app died, an unrecoverable error, or "another tap is already active" | Unbinds the IPC connection to the Tap to Pay Ready app and clears the SDK's transaction state. Enrollment and the kernel app itself are untouched. The next tap re-initializes lazily; call `prepare()` afterward only to avoid paying that latency on the critical path | ```kotlin suspend fun cancelTap() { withContext(Dispatchers.IO) { val sdk = KoardMerchantSdk.getInstance() sdk.cancelTransaction() // abort the tap; leaves the IPC binding in place .onSuccess { println("Tap cancelled") } .onFailure { println("Cancel failed: ${it.message}") } } } suspend fun releaseReaderSession() { withContext(Dispatchers.IO) { val sdk = KoardMerchantSdk.getInstance() sdk.resetKernelService() // unbind IPC; safe after a completed transaction .onSuccess { println("Reader session reset") } .onFailure { println("Reset failed: ${it.message}") } } } ``` **The underlying KiC `cancelTransaction()` API is deprecated.** Visa's 25.06.10 release notes list "Deprecated cancelTransaction() Api" alongside the change that makes the Tap to Pay Ready app call cancel itself when the user presses its cancel button (it now emits `CancelTransactionInitiated` instead of asking your app to cancel). The KiC integration guide has no §3.4 section specifying `cancelTransaction()`'s behavior; it appears only in §3.4.10 as one of the calls that returns a `TransactionResponseMessage`.\ \ Koard's `cancelTransaction()` still calls it, and it is the path the SDK's own tap-timeout timer uses. Treat customer-pressed cancel as handled by the Tap to Pay Ready app, and reach for this wrapper when _your_ code needs to abort a tap. If a future kernel release removes the API, this wrapper is the surface that breaks — pin your integration tests to it. `resetKernelService()` wraps KiC `unbindKernelService()` (KiC integration guide §3.4.12, "Resetting the Service Connection State", implementation _optional_). Visa's own sample calls it once a transaction completes, before navigating to the receipt screen — so it is routine lifecycle hygiene, not just an emergency lever. It resets the connection between the SDK-connector and the Tap to Pay Ready app; it does **not** unenroll the device, uninstall the kernel app, or discard credentials.\ \ It always clears the SDK's internal state, even when the underlying unbind throws (for example because the kernel app is already dead) — the returned `Result` reports the unbind failure, but the next tap flow can still start. Use `sdk.hasActiveTapTransaction()` to check whether a tap is in flight before tearing anything down. ### Account, location, and terminal details Use these suspend lookups to read merchant, location, and terminal configuration (including surcharge and tax details) when building your checkout UI: ```kotlin sdk.getMerchantAccount() // Result sdk.getLocation(locationId) // Result sdk.getTerminal(terminalId) // Result ``` ### Transaction history and receipts ```kotlin // Offset paging over the active location's transactions. KoardTransactionDetails // carries limit/offset/page/total, so advance offset and compare offset + loaded // against total to implement "load more". sdk.getTransactions(limit = 50, offset = 0) // Result sdk.getTransaction(transactionId) // Result // Email, SMS, or both — pass null for the channel you don't want. sdk.sendReceipt( transactionId = transactionId, email = "customer@example.com", phoneNumber = null ) // Result ``` ## Next steps * Review the [Installing the SDK](/docs/setting-up-the-android-sdk/installing-the-sdk) guide for initialization, location selection, and enrollment * Explore the [Demo App](/docs/setting-up-the-android-sdk/demo) to see `MainScreenViewModel` in action * See [SDK Response Codes](/docs/setting-up-the-android-sdk/sdk-response-codes) for the full reference of final statuses, display messages, and error scenarios * Consult `KoardPaymentModels.kt` for `PaymentBreakdown`, `Surcharge`, and other metadata helpers * Check [Supported Devices & NFC Tap Location](/docs/setting-up-the-android-sdk/supported-devices) for device compatibility and where customers should tap ## Best Practices * **[Idempotency](/docs/appendix/glossary#idempotency "Safe retry pattern using event_id so network failures do not double-charge.") everywhere**: Persist a stable `eventId` for each operation that exposes it. Android 1.0.7 capture and tip adjustment have no such argument; reconcile uncertain outcomes before any further attempt. * **UI threading**: Create SDK flows on `Dispatchers.IO` and use `flowOn(Dispatchers.IO)` for their work; collect on Main for UI updates. * **Surface readiness early**: Bind `readinessState` to visible UI (like the demo Settings screen) so operators see why the reader is blocked (developer mode, missing tap-to-pay dependency, no active location). * **Telemetry hooks**: Each `KoardTransactionResponse` carries `statusCode`, `readerStatus`, and `finalStatus`. Log or export these fields for device fleet monitoring and support. * **Lifecycle hygiene**: Cancel the Flow when the hosting Activity/Fragment stops, and call `sdk.cancelTransaction()` for programmatic cancels and timeouts — it aborts the tap without unbinding. Call `sdk.resetKernelService()` when navigating away from the transaction screen — including after a completed transaction, per KiC §3.4.12 — to release the IPC connection. * **NFC registration**: Keep `registerActivityForNfc()` / `unregisterActivityForNfc()` wired into `onResume()` / `onPause()` on every activity that can host a tap. Visa marks these **optional** (KiC §3.3.15.1–2), but they claim NFC foreground dispatch so another `TECH_DISCOVERED` app cannot jump in front of your tap (§3.4.14). # SDK Lifecycle and Deinitialization `deinit()` ends the SDK's current setup so your app can initialize it again without restarting. Use it when replacing the API key, changing environments, switching to a different merchant on the device, or testing a complete reset. These examples require **Android SDK 1.0.7 or later** and apply to UAT and Production. ## Choose the right cleanup operation | Operation | What it does | What you do next | |---|---|---| | `logout()` | Clears the merchant session and active location; preserves SDK initialization and device enrollment | Log in again and select a location | | `unenrollDevice()` | Attempts Visa unenrollment and clears local enrollment certificates, keys, device identifiers, and enrollment state; keeps the SDK initialized | Enroll again before accepting taps | | `deinit()` | Unenrolls, logs out, releases the reader service, clears the SDK's API key, and removes the SDK instance | Initialize again, log in, select a location, and enroll | Use `logout()` for an ordinary sign-out when the device will continue with the same merchant. Use `deinit()` for a complete reset. For canceling a tap or releasing only the reader connection, see [Running Payments](running-payments.md#canceling-the-reader-session). ## What deinit does The SDK runs these cleanup steps in order: 1. Calls `unenrollDevice()` before logging out. It attempts to unenroll with Visa and clears local enrollment state, including device keys, certificates, the Visa device ID, and saved enrollment details. Local enrollment state is cleared even if Visa unenrollment fails. 2. Calls `logout()` to clear the merchant session and active location. 3. Resets the connection to the Visa reader service. 4. Clears the API-key provider and saved key fingerprint, removes the singleton instance, and cancels its internal coroutine scope. Cleanup is best effort: a Visa unenrollment failure does not prevent the remaining cleanup. A completed local reset is not confirmation that remote unenrollment succeeded. Deinitialization does not revoke your API key, delete the merchant or transaction records in Koard, or uninstall Visa Tap to Pay Ready. ## Check initialization state ```kotlin import com.koardlabs.merchant.sdk.KoardMerchantSdk val sdkInitialized = KoardMerchantSdk.isInitialized() ``` This static check is safe before initialization and from any thread. It returns `false` before `initialize()` and after `deinit()` completes. `getInstance()` throws when no instance exists. Initialization only means an SDK instance exists. It does not prove the merchant is authenticated, enrolled, or ready to accept a payment. While initialized, observe `sdk.readinessState` and require `isReadyForTransactions` before starting a tap. ## Deinitialize and initialize again Call both lifecycle operations on a worker thread. The helpers below switch to `Dispatchers.IO`: ```kotlin import android.app.Application import com.koardlabs.merchant.sdk.KoardMerchantSdk import com.koardlabs.merchant.sdk.domain.KoardEnvironment import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext suspend fun deinitializeSdk() = withContext(Dispatchers.IO) { if (KoardMerchantSdk.isInitialized()) { KoardMerchantSdk.getInstance().deinit() } check(!KoardMerchantSdk.isInitialized()) } suspend fun initializeSdk( application: Application, apiKey: String, environment: KoardEnvironment ) = withContext(Dispatchers.IO) { if (!KoardMerchantSdk.isInitialized()) { KoardMerchantSdk.initialize( application = application, apiKey = apiKey, environment = environment ) } } ``` Use `KoardEnvironment.UAT` with your UAT key or `KoardEnvironment.PROD` with your production key. Your app supplies the key again; the deinitialized SDK cannot recover it for you. Keep it in your app's existing credential/configuration mechanism and never print it in logs. Run deinitialization after payment and reader work has finished. Use an application/service coroutine scope that remains alive during cleanup, and await completion before offering Initialize or launching another SDK operation. The initialization flag is a snapshot, not a lock or a stream of cleanup progress; keep a separate UI busy state while deinitializing. After initialization, obtain a **new** `KoardMerchantSdk.getInstance()`. Discard references to the old SDK and subscribe to the new instance's readiness state. Then log in, choose the active location, and [enroll and check readiness](installing-sdk.md) again before accepting taps. Android deinit clears enrollment, so initialization alone cannot restore payment readiness. ## Verify the flow in the demo In the public 1.0.7 demo, choose **Settings → Reset SDK and sign out**. The demo deinitializes and reinitializes the SDK, reattaches NFC, and returns to Login. Sign in, select a location, and enroll again. Settings shows `isInitialized()` separately from reader readiness. The demo retains its own test configuration; this does not mean the SDK retains the key after deinit. The same flow works in the UAT and Production builds with the corresponding configuration. ## See also - [Transaction Metadata](transaction-metadata.md) - [Running Payments](running-payments.md) - [iOS SDK Lifecycle](../../ios-sdk/details/sdk-lifecycle.md) — iOS preserves device enrollment during deinit # Transaction Metadata on Android Attach your own JSON context to a sale, preauthorization, or refund with the optional `metadata` parameter. These examples require **Android SDK 1.0.7 or later** and work in UAT and Production. For a supplied AAR, follow [Manual AAR Installation](installing-sdk.md#fallback-manual-aar-installation). ## What is stored Metadata is an optional JSON object containing your application's context, such as an order ID, invoice reference, cart ID, or refund reason. Use it to reconcile Koard events with your own records and explain why a payment operation occurred. It belongs to the **individual transaction event** that the request creates. It is separate from `eventId` (idempotency), `transactionId` (the payment lifecycle), and payment settings such as amount, tax, tip, or surcharge. Putting those settings in metadata does not configure the payment. - Use string keys and JSON values: strings, numbers, booleans, nulls, arrays, or nested objects. Convert dates and custom application objects to JSON-compatible values first. - Omitting metadata stores no custom metadata for that event. An empty object `{}` is valid and is distinct from no value. - A refund can carry different metadata from the original sale. Later events do **not** automatically copy or merge the previous event's metadata. Pass the order reference again when you want it on a refund. - Metadata is stored by Koard for retrieval. It is not a processor configuration field or a guarantee of text appearing on a receipt. - Include business references only; do not include card numbers, CVVs, PINs, API keys, session tokens, or other secrets. ## Send metadata with a sale or preauth Amounts use `Int` minor currency units in SDK 1.0.7. Use Android's `org.json.JSONObject`. You do not need the SDK's internal serialization types. The example assumes the SDK is initialized, the merchant is signed in, a location is selected, and the reader is enrolled and ready. ```kotlin import android.app.Activity import com.koardlabs.merchant.sdk.KoardMerchantSdk import com.koardlabs.merchant.sdk.domain.KoardTransactionResponse import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.withContext import org.json.JSONObject suspend fun saleWithMetadata( activity: Activity, eventId: String, onResponse: suspend (KoardTransactionResponse) -> Unit ) { val metadata = JSONObject() .put("order_id", "order-123") .put("channel", "mobile") .put("fulfillment", JSONObject().put("pickup", true)) val flow = withContext(Dispatchers.IO) { KoardMerchantSdk.getInstance().sale( activity = activity, amount = 1250, // $12.50 in minor currency units currency = "USD", eventId = eventId, metadata = metadata ) }.flowOn(Dispatchers.IO) flow.collect { response -> onResponse(response) } } ``` Use a UUID4 for `eventId` and follow the [idempotency guidance](../../payments/details/idempotency.md) for retries. Handle every reader/transaction response as in [Running Payments](running-payments.md), and save the returned `transactionId`. Invoke this helper from a Main/UI coroutine. Flow work runs on IO; the callback stays on the caller's dispatcher for UI updates. Catch errors around the helper as in Running Payments. For an authorization without capture, replace `sale(...)` with `preauth(...)`; the `activity`, `amount`, `currency`, `eventId`, and `metadata` arguments above are the same. Run the sale or the preauth according to your payment flow. ## Send metadata with a refund ```kotlin import com.koardlabs.merchant.sdk.KoardMerchantSdk import com.koardlabs.merchant.sdk.domain.KoardTransaction import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.json.JSONObject suspend fun refundWithMetadata( transactionId: String, eventId: String ): KoardTransaction = withContext(Dispatchers.IO) { KoardMerchantSdk.getInstance().refund( transactionId = transactionId, amount = 500, // $5.00; null requests a full refund eventId = eventId, metadata = JSONObject() .put("order_id", "order-123") .put("return_id", "return-456") .put("reason", "customer_return") ).getOrThrow() } ``` This overload is a backend refund with no tap. `refundEmv(...)` and the Activity-based `refund(..., withTap = true)` overload also accept `metadata` for a card-present refund and emit a transaction flow. ## How it reaches Koard For sale/preauth taps, the SDK puts your JSON object inside the transaction data it passes to Visa KiC as `merchantCustomInfo`. KiC delivers that context to Koard's Android authorization endpoint, which stores your metadata with the event. The SDK handles the JSON/Base64 transport encoding; pass a `JSONObject`, not a pre-encoded string. For a backend refund, the SDK sends `metadata` in the refund request body. Omit it or pass `null` when no custom data is needed. Adding metadata does not change the payment amount, reader interaction, or response flow. ## Retrieve and view metadata The current SDK transaction response models do not expose a typed `metadata` property. Retrieve the raw transaction JSON through your backend or an API client using an API key authorized to read that merchant's transactions. Save the returned transaction ID, then call `GET /v1/transactions/{transaction_id}`. The top-level `metadata` belongs to the current event; `history[].metadata` belongs to each earlier event. For one specific operation, call `GET /v1/transactions/event/{event_id}` using the event ID sent with that operation. For example, from your backend or a local API client with `KOARD_API_KEY` set securely: ```bash KOARD_BASE_URL="https://api.uat.koard.com" TRANSACTION_ID="your-transaction-id" curl --fail-with-body --silent --show-error \ "$KOARD_BASE_URL/v1/transactions/$TRANSACTION_ID" \ -H "x-koard-apikey: $KOARD_API_KEY" \ -H "Accept: application/json" ``` For Production, use `https://api.koard.com` and a production key. A transaction ID and key must belong to the same environment and authorized account scope. A response after a refund may contain these fields (other transaction fields omitted): ```json { "transaction_id": "your-transaction-id", "event_id": "refund-event-id", "transaction_type": "refund", "metadata": { "order_id": "order-123", "return_id": "return-456", "reason": "customer_return" }, "history": [ { "event_id": "sale-event-id", "metadata": { "order_id": "order-123", "channel": "mobile" } } ] } ``` If a later event has `metadata: null`, check the original event in `history` or use its event lookup; null at the top level does not mean the sale's metadata was deleted. Likewise, older transactions and requests that omitted metadata can legitimately return null. To display metadata inside your app, have your backend retrieve it and return the fields your UI needs. Do not assume the SDK demo receipt or MMS transaction screen displays arbitrary metadata; the raw API response is the verification path described here. ## Verify in UAT and Production 1. Send a sale or preauth with a recognizable `order_id` and retain its event ID and returned transaction ID. 2. Retrieve that event in the same environment and confirm the metadata values and JSON types. 3. If testing a refund, pass a separate `return_id` and retain the sale's `order_id` explicitly. Retrieve the refund event and the transaction history to compare them. 4. Reinitializing or deinitializing the SDK does not erase metadata already stored in Koard. Log in again before continuing SDK operations. ## See also - [SDK Lifecycle and Deinitialization](sdk-lifecycle.md) - [Running Payments](running-payments.md) - [iOS Transaction Metadata](../../ios-sdk/details/transaction-metadata.md) # SDK Response Codes & Error Handling Understand how the Koard Android SDK surfaces errors and transaction outcomes — including the `KoardException` / `KoardError` types your app catches, transaction response codes, display messages, and error scenarios. **What you learn** * The two error channels: **`KoardException`** (thrown) vs **`KoardTransactionResponse`** (emitted) * The `KoardError` and `KoardErrorType` sealed hierarchy your app inspects for error details * How the SDK wraps all underlying Visa KiC errors into Koard types — you never handle raw KiC exceptions * The final transaction statuses: **Approve**, **Decline**, **Abort**, **Failure**, **AltService**, and **Unknown** * What `statusCode` means and the numeric codes the underlying Visa KiC kernel sends * Display message IDs shown during the tap-to-pay flow * Common abort and error scenarios and how to handle them ## Error Model Overview The SDK surfaces errors through **two channels** depending on context: | Channel | When | How | What to inspect | | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | --------------- | ---------------------------------------------------------------------- | | **`KoardException`** (thrown) | Non-transaction operations: enrollment, SDK init, API calls (`capture`, `refund`, `reverse`, `adjust`), validation | `try / catch` | `exception.error.errorType` — a `KoardErrorType` sealed class | | **`KoardTransactionResponse`** (emitted) | During sale, preauth, partial-completion, and Activity-based tap-refund flows | Callback / Flow | `response.actionStatus`, `response.finalStatus`, `response.statusCode` | **Refund overloads differ.** The reference-based `refund(transactionId, ...)` returns `Result`. The Activity-based `refund(..., withTap = true)` and `refundEmv(...)` return `Flow` for a new card tap. Handle thrown readiness errors as well as emitted events. **You never handle raw KiC exceptions.** The SDK catches every `KiCSdkException` from the Visa Kernel in the Cloud SDK and maps it to a `KoardException` with a typed `KoardErrorType`. Your app only needs to handle Koard types. ## KoardException & KoardError `KoardException` is the main exception thrown by the SDK for all non-transaction-flow errors. It wraps a `KoardError` with a human-readable message and a typed error classification: ```kotlin class KoardException( cause: Throwable? = null, val error: KoardError = KoardError( shortMessage = "Koard Merchant SDK has encountered a fatal error. ...", errorType = KoardErrorType.GeneralError ) ) : Exception(error.shortMessage, cause) data class KoardError( val shortMessage: String, // Human-readable error description val errorType: KoardErrorType // Typed error classification (sealed hierarchy) ) ``` **The underlying engine exception is never attached as `cause`.** When the SDK maps a `KiCSdkException` from the Visa Kernel in the Cloud, it deliberately does _not_ set it as the `KoardException.cause` — this keeps third-party SDK identifiers out of your crash reporters and means you never need a transitive dependency on the engine's exception types. The numeric code and any human-readable detail are folded into `error.shortMessage` and `error.errorType`. ### Catching KoardException ```kotlin try { withContext(Dispatchers.IO) { sdk.capture(transactionId, amount).getOrThrow() } } catch (e: KoardException) { when (e.error.errorType) { is KoardErrorType.KoardServiceErrorType.HttpError -> { val code = (e.error.errorType as KoardErrorType.KoardServiceErrorType.HttpError).errorCode showError("Server error (HTTP $code): ${e.error.shortMessage}") } is KoardErrorType.KoardServiceErrorType.ConnectionError -> showError("Network error — check your connection") is KoardErrorType.KoardServiceErrorType.Unauthorized -> showError("Session expired — please log in again") is KoardErrorType.VACEnrollmentError -> showError("Enrollment failed — re-enroll the device") else -> showError(e.error.shortMessage) } } ``` ## KoardErrorType Reference `KoardErrorType` is a sealed class hierarchy. Every error the SDK produces maps to one of these types. ### Top-Level Error Types | Error Type | When It Occurs | | --------------------------- | -------------------------------------------------------------------------- | | `GeneralError` | Catch-all for unmapped or unexpected errors | | `CertificateError` | TLS or certificate validation failure | | `BLEError` | Bluetooth/peripheral communication failure | | `MainThreadError` | SDK method called on the main thread (must use a worker thread) | | `NfcTransactionError` | NFC transaction-level failure | | `VACEligibilityError` | Device failed Visa Acceptance Cloud eligibility check (e.g., Android < 12) | | `DeviceNotProvisionedError` | Device has not been provisioned for Tap to Pay | | `VACEnrollmentError` | Enrollment with the Visa Acceptance Cloud failed | ### NotReady — Blocked Before the Tap Starts `sale()`, `preauth()`, `completePartialAuth()`, and `refundEmv()` refresh their readiness checks first and **throw** a `KoardException` whose `error.errorType` is a `KoardErrorType.NotReady.*` value if the SDK cannot transact. The Flow is never returned, so wrap the call itself — not just the collection — in `try / catch`. `KoardSdkReadiness.notReadyReason()` returns the same typed reason without attempting a transaction. | Error Type | Meaning | What to do | | ------------------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | | `KernelAppNotInstalled` | The Visa Tap to Pay Ready app isn't installed | Route the user to install it (`installKernelApp(activity)`) | | `DeveloperModeEnabled` | Developer options are on | Prompt the operator to disable developer mode | | `NotAuthenticated` | No merchant session | Call `login(...)` | | `NotEnrolled` | The device has not been enrolled | Set an active location, then call `enrollDevice()` | | `NoActiveLocation` | No active location selected | Call `setActiveLocation(locationId)` | | `ReaderNotStarted` | Enrolled with a location, but the reader connection was never started or was killed | Call `prepare()` to bring the session back up before retrying the tap | | `Preparing` | Enrolling, generating certificates, the payment processor is coming up, or a location switch is in flight | **Transient** — retry shortly | | `CertificateFailed(error)` | Certificate generation failed | Inspect `error`; re-run `refreshDeviceCertificates()` | | `EnrollmentFailed(error)` | Enrollment failed | Inspect `error`; see [Troubleshooting](/docs/setting-up-the-android-sdk/troubleshooting) | | `PaymentProcessorFailed(error)` | The thin client failed to start | Inspect `error`; `resetKernelService()` to release the IPC connection, then retry (optionally `prepare()` first to re-warm) | `KoardErrorType` and its nested `KoardServiceErrorType` / `KicConnectorError` are `sealed`, and 1.0.6 added the members above plus `KoardServiceErrorType.UnparseableResponse` and `KicConnectorError.KernelAppBusyWithAnotherMerchant`. Any exhaustive `when` over these types will stop compiling until you add the new branches or an `else`. ### KoardServiceErrorType — API / HTTP Errors Thrown when SDK methods call the Koard REST API (`capture`, `refund`, `reverse`, `adjust`, `getTransaction`, etc.): | Error Type | Description | | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | | `HttpError(errorCode: Int)` | Server returned an HTTP error — inspect `errorCode` for the status (400, 401, 404, 500, etc.) | | `InvalidRequest` | Request validation failed before sending (e.g., negative amount, missing transaction ID) | | `NotFound` | Resource not found (404) | | `Unauthorized` | Missing or invalid [API key](/docs/appendix/glossary#api-key "Secret header credential for REST calls; v5 keys use scoped permissions.") / session (401) | | `UnexpectedError` | Unexpected server error or empty response body | | `UnparseableResponse` | The response body could not be deserialized | | `ConnectionError` | Network unreachable, DNS failure, or timeout | ### DeviceIntegrityError — Security Checks Thrown when the device fails security validation during enrollment or transaction preparation: | Error Type | KiC Code | Description | | ------------------------- | -------- | ------------------------------------------------------ | | `EmulatorDetected` | 1000 | Running on an emulator — use a physical device | | `RootDetected` | 1001 | Device is rooted or has superuser binaries | | `TamperDetected` | 1002 | Device tamper detection triggered | | `DeveloperModeEnabled` | 2000 | Developer options must be disabled | | `DebugModeEnabled` | 2001 | USB debugging must be disabled | | `HookDetected` | 2003 | Runtime instrumentation detected (Frida, Xposed, etc.) | | `GenericIntegrityFailure` | -1 | Generic device integrity attestation failure | ### TransactionErrorType — Card & Payment Errors These appear as the `errorType` on a `KoardException` when a transaction-level error is mapped from the KiC thin client. They correspond to EMV-level outcomes: | Error Type | Description | | ---------------------------------- | -------------------------------------------------- | | `TransactionAmountNonPositive` | Amount must be greater than zero | | `RefundMissingParentTransactionId` | Refund requires a parent transaction ID | | `CancelOrEnter` | Cardholder prompted to cancel or confirm | | `CardError` | Unrecoverable card data error | | `NotAuthorisedOrDeclined` | Issuer declined the transaction | | `PinRequired` | PIN entry is required | | `IncorrectPin` | Cardholder entered an incorrect PIN | | `ProcessingError` | Generic processing failure | | `TryAnotherCard` | Card cannot complete — try a different card | | `InsertOrSwipe` | Contactless not supported — use chip or mag-stripe | | `TryAnotherChoice` | Try a different payment method | | `Cancelled` | Transaction was cancelled | | `StrongCvm` | Strong Customer Verification required (SCA) | | `PinBypassed` | PIN entry was bypassed | | `PinNotProvided` | PIN was requested but not provided | | `TransactionNotAllowed` | Transaction type not allowed on this card/terminal | | `NotApplicable` | Status not applicable to this transaction type | | `UnknownStatus` | Unmapped status from the kernel | | `TransactionError` | Generic transaction error | | `EnableReader` | NFC reader needs to be enabled | | `NetworkError` | Network error during transaction processing | | `AuthenticationFailed` | Authentication with the payment backend failed | | `CouldNotAttestError` | Device attestation failed during transaction | | `AsiError` | Visa auth service interface error | | `TcConfigError` | Thin client configuration error | | `VACResponseFailedError` | VAC response indicated failure | | `VACResponseParseError` | Could not parse VAC response | | `TransactionInProgressError` | Another transaction is already in progress | | `DeviceDisabledError` | Device has been disabled for transactions | | `ErrorLoadingConfig` | Could not load transaction configuration | | `VACInternalError` | Internal VAC error | | `TransactionApprovedUploadFailed` | Transaction approved but receipt upload failed | ### KiC Connector Errors Thrown when the SDK cannot communicate with the Visa [Tap to Pay](/docs/appendix/glossary#tap-to-pay-android "Contactless acceptance on NFC Android devices using the Visa Tap to Pay Ready kernel.") Ready kernel app: | Error Type | KiC Code | Description | | ---------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `BindingError` | 92 | Failed to bind to the Visa kernel service | | `ConnectorSendError` | 93 | Sending a message to the kernel failed | | `KernelParseError` | 94 | Kernel response could not be parsed | | `ConnectorParseError` | 95 | Connector-side serialization failed | | `KernelAppNotInstalled` | 96 | Visa kernel app missing — install from Google Play | | `PlayProtectOrVerifyAppDisabled` | 97 | Google Play Protect must be enabled | | `KernelAppBusyWithAnotherMerchant` | 98 | Another merchant app on the device currently holds the lock on the Tap to Pay Ready kernel service (KiC multi-tenancy). Close the other app or wait for it to finish — calling `resetKernelService()` will **not** help because the lock is owned by a different process | ### KiC General Errors | Error Type | KiC Code | Description | | -------------------------------- | -------- | ---------------------------------------- | | `NoNetworkOrTimedOut` | 10 | Network unavailable or request timed out | | `UnsupportedAndroidVersion` | 21 | Device OS below Android 13 | | `TapToPayReadyAppUpdateRequired` | 62 | Visa Tap to Pay Ready app is outdated | ### KiC Eligibility Errors | Error Type | KiC Code | Description | | -------------------------------------------- | -------- | --------------------------------------- | | `UnsupportedOs` | 80 | OS build is unsupported | | `HardwareKeystoreNotPresent` | 81 | No hardware-backed keystore | | `ECEncryptionNotAvailable` | 82 | Elliptic-curve crypto unavailable | | `AESEncryptionNotAvailable` | 83 | AES crypto unavailable | | `DESEncryptionNotAvailable` | 84 | DES crypto unavailable | | `NfcNotAvailable` | 85 | NFC hardware missing or disabled | | `GooglePlayServicesNotAvailableOrOldVersion` | 86 | Google Play Services absent or outdated | | `EligibilityCheckFailed` | -1 | Generic eligibility failure | ### KiC Initialize Errors | Error Type | KiC Code | Description | | ----------------------- | -------- | ------------------------------------------- | | `AlreadyEnrolled` | 1 | Device already enrolled — no action needed | | `DeviceAuthPubKidEmpty` | 3 | Missing device-auth public key — re-enroll | | `VacDeviceIdEmpty` | 4 | VAC device ID not provided | | `XRandomValueEmpty` | 52 | Random nonce required by enrollment missing | | `Failed` | -1 | Generic initialization failure | ### KiC Prepare Errors Pre-transaction secure channel setup failures: | Error Type | KiC Code | Description | | -------------------------------- | -------- | ----------------------------------------------------- | | `AuthenticationFailed` | 7 | VAC authentication failed | | `SdkInitNotDone` | 11 | `init()` not completed before use | | `SdkEnrollNotDone` | 12 | `enrollDevice()` not completed — re-enroll | | `ErrorLoadingConfig` | 17 | Could not load config blobs | | `AsiError` | 18 | Visa auth service interface error | | `HardwareKeystoreNotPresent` | 20 | Hardware keystore missing during key prep | | `AttestationFailed` | 24 | Device attestation failed | | `DoLoginFailed` | 25 | Login exchange with Visa backend failed | | `NullLoginAssertion` | 26 | Login response missing assertion | | `NullLoginCrypto` | 27 | Login response missing crypto payload | | `NullLoginResponse` | 28 | Entire login response was null | | `NullLoginResponseBody` | 29 | Login HTTP body empty | | `NullLoginResponseAuthStatus` | 30 | Login response missing auth status | | `NullSharedSecret` | 31 | Shared secret not derived — re-enroll | | `NullSessionKeys` | 32 | Session keys missing — re-enroll | | `FailedResponseVerification` | 33 | MAC/signature mismatch — possible tampering | | `FailedMacTagVerification` | 34 | MAC tag verification failed | | `FailedAuthStatus` | 36 | Visa backend rejected authorization | | `EmptyAuthStatus` | 37 | Auth status element empty | | `GetSeedListFailure` | 49 | Could not fetch key-rotation seed list | | `CertificatePinningError` | 50 | TLS pinning failed — possible MITM | | `TransactionKeyDerivationFailed` | 51 | Could not derive transaction keys — re-enroll | | `KeyRotationNeeded` | 87 | Kernel requested key rotation (handled automatically) | | `KeyRotationNotNeeded` | 88 | Key rotation not needed (informational) | | `KeyRotationSuccess` | 89 | Key rotation completed (informational) | | `KeyRotationFailure` | 90 | Key rotation failed — re-enroll if transactions fail | | `KeyRotationNullResponse` | 91 | Kernel did not return rotation status | ## Transaction Response Flow `KoardTransactionResponse` emitted by `sdk.sale()` or `sdk.preauth()` can include an **action status** that tells your app what stage the transaction is in. When present, use this to drive your UI: | Action Status | Meaning | What to do | | ------------- | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | `OnProgress` | Transaction is in flight — the reader is active | Update your UI with the current `displayMessage` and `readerStatus` | | `OnFailure` | Read failure or terminal failure | A retryable hint with no final status keeps the same flow active; otherwise retain any transaction and display the failure | | `OnComplete` | The reader session has finished | Inspect `transaction.status` and nullable `finalStatus`, including partial approval and surcharge decisions | Use the complete [Running Payments handler](/docs/setting-up-the-android-sdk/running-payments#launching-a-sale-session). Do not force-unwrap `response.transaction`, assume `finalStatus` is always present, or show an approved receipt before checking partial and surcharge states. ## Final Transaction Statuses When present on completion, `finalStatus` has one of these values. It can be null; the returned transaction still carries its persisted status: | Final Status | Description | Typical Cause | | ------------------------ | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | | **`Approve`** | Transaction was authorized by the issuer | Successful payment — display receipt with approval code and transaction details | | **`Decline`** | Transaction was explicitly declined | Issuer denied the authorization, card restricted, insufficient funds, or Strong CVM required (SCA interface switch) | | **`Abort`** | Transaction was terminated before completion | User cancelled, PIN entry cancelled, device security issue, NFC read failure, timeout, or network loss | | **`Failure`** | An internal or system-level error prevented the transaction | SDK/kernel error, device misconfiguration, or unexpected processing failure | | **`AltService`** | Card requested an alternative service | The card network indicated that an alternative acceptance method should be used | | **`Unknown(rawStatus)`** | The kernel returned a status the SDK could not map | Inspect `rawStatus` for the original value; treat as a non-approval | The SDK consolidates the underlying processor response into these statuses so your app does not need to interpret raw processor-level codes. The original acquirer `responseCode` (ISO 8583 field 39) is still available in the transaction receipt for logging and support purposes. ## Persisted Transaction Status The reader `finalStatus` above describes the outcome of a single tap. The persisted transaction itself (`KoardTransaction.status`, also returned by `getTransaction`/`getTransactions` and post-reader operations) uses the `KoardTransactionStatus` enum: | Status | Description | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `PENDING` | Transaction created but not yet finalized | | `AUTHORIZED` | Funds authorized ([preauth](/docs/appendix/glossary#preauth "Authorization hold without capture; must later capture or reverse.")/hold) | | `CAPTURED` | Authorization captured/settled | | `SETTLED` | Settled with the processor | | `DECLINED` | Declined by the issuer | | `REFUNDED` | Fully or partially refunded | | `REVERSED` | Reversed/voided | | `CANCELED` | Canceled before completion | | `ERROR` | Errored out | | `SURCHARGE_PENDING` | Awaiting customer [surcharge](/docs/appendix/glossary#surcharge "Fee on eligible credit-card transactions to offset processing costs (not debit).") confirmation — call `sdk.confirm(transactionId, confirm = ...)` | | `UNKNOWN` | Unmapped status | A transaction is refundable when its status is `AUTHORIZED`, `CAPTURED`, `SETTLED`, or `PENDING` (exposed as `KoardTransactionStatus.isRefundable`). ## Acquirer Authorization Statuses Behind the scenes, the acquirer returns a more granular `authStatus` in the authorization response. The SDK maps these to the final statuses above, but they are available in the transaction details for advanced use cases: | Auth Status | Description | Maps to Final Status | | ------------------ | ------------------------------------------------------------------------ | ------------------------------------------- | | `Approve` | Issuer approved the transaction | `Approve` | | `Decline` | Issuer declined the transaction (also used for internal acquirer errors) | `Decline` | | `PartialApproval` | Issuer approved a lesser amount than requested | `Approve` (with reduced `authorizedAmount`) | | `InvalidPIN` | The PIN entered by the cardholder was incorrect | `Decline` | | `UnableToGoOnline` | The terminal could not connect to the acquirer for online authorization | `Decline` or `Abort` | | `AdditionalInfo` | Acquirer returned supplementary information (e.g., referral) | Varies | ## Transaction Response Details When a transaction completes (regardless of outcome), the `KoardTransactionResponse` contains the following fields: | Field | Type | Description | | ----------------------- | ------------------------------- | -------------------------------------------------------------------------------------------------------- | | `transactionId` | `String?` | Unique identifier for the transaction, when available | | `finalStatus` | `KoardTransactionFinalStatus?` | Terminal outcome, when available: `Approve`, `Decline`, `Abort`, `Failure`, or `AltService` | | `actionStatus` | `KoardTransactionActionStatus?` | Current action phase, when available: `OnProgress`, `OnFailure`, or `OnComplete` | | `readerStatus` | `KoardReaderStatus` | Reader state: `preparing`, `readyForTap`, `cardDetected`, `processing`, `complete`, etc. | | `displayMessage` | `String?` | Human-readable message from the reader/kernel | | `statusCode` | `Int?` | Numeric status code from the Visa KiC kernel — see [Status Code Reference](#status-code-reference) below | | `statusCodeDescription` | `String?` | Human-readable description of the status code (auto-generated from the code) | | `transaction` | `KoardTransaction?` | Full transaction object (populated on completion) | ## Status Code Reference The `statusCode` field on `KoardTransactionResponse` is a numeric integer forwarded from the underlying **Visa Kernel in the Cloud (KiC)** SDK. These codes are passed through on the transaction response for troubleshooting and logging. **These same codes drive the `KoardErrorType` mapping.** When the SDK catches a `KiCSdkException` with one of these codes, it maps it to the corresponding `KoardErrorType` documented in the [KoardErrorType Reference](#koarderrortype-reference) above. You don't need to handle numeric codes directly — use `KoardErrorType` pattern matching instead. For most apps, routing on `actionStatus` + `finalStatus` is sufficient. The `statusCode` is useful for **debugging**, **logging**, and handling edge cases like re-enrollment (`12`) or developer mode (`2000`). The status codes fall into several categories based on what layer of the KiC stack generated them: ### Connector Status (92–97) — Service Binding Failures These indicate problems communicating between the Koard SDK and the Visa Tap to Pay Ready app installed on the device. | Code | Description | What to do | | ---- | --------------------------------------------------- | ---------------------------------------------------------------- | | `92` | Failed to bind to the Visa kernel service | Ensure the Visa Tap to Pay Ready app is installed and up to date | | `93` | Sending a message to the kernel service failed | Retry the operation; if persistent, restart both apps | | `94` | Kernel response payload could not be parsed | Update the Visa Tap to Pay Ready app | | `95` | Connector-side serialization/deserialization failed | Update the Koard SDK to the latest version | | `96` | Visa kernel service app missing on device | Install the Visa Tap to Pay Ready app from Google Play | | `97` | Google Play Protect / Verify Apps is disabled | Enable Play Protect in Google Play settings | ### General Status (10, 21, 62) — Environment Readiness | Code | Description | What to do | | ---- | -------------------------------------------- | ------------------------------------------------ | | `10` | Network unavailable or SDK request timed out | Check network connectivity and retry | | `21` | Device OS level not supported by Tap to Pay | Device must run Android 13 (API 33) or later | | `62` | Visa Tap to Pay Ready app is outdated | Update the Tap to Pay Ready app from Google Play | ### Eligibility Status (80–86) — Device Capability Checks Returned when `checkKiCEligibility()` detects a device hardware or software limitation. | Code | Description | What to do | | ---- | ---------------------------------------------- | ------------------------------------------------------------ | | `80` | OS flavor/build is unsupported | Device uses an incompatible Android build (e.g., custom ROM) | | `81` | Device lacks a hardware-backed keystore | Device does not meet security requirements | | `82` | Elliptic-curve crypto APIs missing or disabled | Device crypto hardware insufficient | | `83` | AES crypto acceleration unavailable | Device crypto hardware insufficient | | `84` | DES crypto unavailable | Device crypto hardware insufficient | | `85` | NFC hardware missing or disabled | Enable NFC in device settings, or device has no NFC | | `86` | Google Play Services absent or out of date | Install or update Google Play Services | ### Initialize Status (1, 3, 4, 52) — Enrollment & Bootstrap | Code | Description | What to do | | ---- | ---------------------------------------------- | ---------------------------------------------------------- | | `1` | Device already enrolled for Tap to Pay | No action needed — the device is already set up | | `3` | Missing device-auth public key identifier | Re-run the enrollment flow | | `4` | Merchant/VAC device ID not provided | Ensure the SDK is configured with a valid merchant profile | | `52` | Random nonce required by enrollment is missing | Re-run the enrollment flow | ### Security Status (1000–2003) — Device Integrity | Code | Description | What to do | | ------ | ------------------------------------------- | ----------------------------------------------------------- | | `1000` | Emulator detected | Tap to Pay cannot run on emulators — use a physical device | | `1001` | Device rooted or superuser binaries present | Device must not be rooted | | `1002` | Device tamper detection triggered | Device has been modified and is not trusted | | `2000` | Developer options must be disabled | Disable developer mode before running transactions | | `2001` | USB debugging/logging must be disabled | Turn off USB debugging in developer options | | `2003` | Runtime hook/instrumentation detected | Remove any instrumentation frameworks (Frida, Xposed, etc.) | ### Prepare Status (7–91) — Pre-Transaction Secure Channel These codes occur during `startUpSdk()` or when the SDK prepares for a transaction. They relate to the secure channel between the device and the Visa backend. | Code | Description | What to do | | ---- | -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | | `7` | VAC authentication call to Visa failed | Check API credentials and network connectivity | | `11` | `init()` not completed before use | Complete SDK initialization before starting transactions | | `12` | `enrollDevice()` not completed | Device needs enrollment — show the enrollment UI and re-enroll. This also occurs if the Tap to Pay Ready app was reinstalled | | `17` | Could not load enrollment/transaction config blobs | Re-initialize the SDK | | `18` | ASI (Visa auth service interface) returned error | Transient backend issue — retry | | `20` | Hardware keystore missing when preparing keys | Device does not meet security requirements | | `24` | Device attestation failed or invalid | Re-enroll the device; ensure Play Protect is enabled | | `25` | Login exchange with Visa backend failed | Check network; retry | | `26` | Login response missing assertion blob | Transient backend issue — retry | | `27` | Login response missing crypto payload | Transient backend issue — retry | | `28` | Entire login response was null | Transient backend issue — retry | | `29` | Login HTTP body empty | Transient backend issue — retry | | `30` | Login response missing auth status | Transient backend issue — retry | | `31` | Shared secret not derived | Re-enroll the device | | `32` | Session keys missing | Re-enroll the device | | `33` | MAC/signature mismatch in response | Possible tampering — re-enroll the device | | `34` | MAC tag verification failed | Possible tampering — re-enroll the device | | `36` | Visa backend explicitly rejected authorization | Check merchant configuration with Koard support | | `37` | Auth status element empty | Transient backend issue — retry | | `49` | Could not fetch key-rotation seed list | Check network connectivity | | `50` | TLS pinning check failed | Possible man-in-the-middle — check network security | | `51` | Could not derive transaction keys | Re-enroll the device | ### Key Rotation Status (87–91) | Code | Description | What to do | | ---- | ------------------------------------------- | ------------------------------------------------- | | `87` | Kernel requested key rotation | SDK handles this automatically — no action needed | | `88` | Key rotation already satisfied (not needed) | Informational — no action needed | | `89` | Key rotation completed successfully | Informational — no action needed | | `90` | Key rotation failed | Re-enroll the device if transactions fail | | `91` | Kernel did not return key rotation status | Re-enroll the device if transactions fail | ### Prepare Progress (70–75) — `prepare()` Warm-Up The `sdk.prepare()` warm-up flow emits `KoardPrepareResponse` objects whose `status` is a `KoardPrepareStatus`. Codes 70–75 are the normal lifecycle; anything in the 80–199 range is surfaced as `KoardPrepareStatus.Error(code)`, and an unrecognized code becomes `KoardPrepareStatus.Unknown(code)`. These progress codes are informational — wait for `Done` (or an `Error`) rather than handling each one. | Code | `KoardPrepareStatus` | Meaning | | ---------- | -------------------------- | -------------------------------------------------------------------------------------------------------------------------- | | `70` | `Called` | KiC accepted the `prepare()` invocation | | `71` | `AuthenticationInProgress` | Authenticating the device against the backend | | `72` | `AttestationInProgress` | Performing Play Integrity / device attestation | | `73` | `GettingConfigurations` | Downloading transaction configurations | | `74` | `ParsingConfigurations` | Parsing the downloaded configurations | | `75` | `Done` | Prepare completed — SDK is ready for transactions | | `80`–`199` | `Error(code)` | Prepare did not reach `Done` (auth/attestation failure, key rotation, network error, etc.) — retry on the next user action | ### Transaction Status — In-Progress Codes These codes appear during an active transaction and are reflected in the `readerStatus` field: | Code | Reader Status | Description | | ----- | --------------- | ----------------------------------------------------------- | | `109` | `readyForTap` | POS state started — reader is waiting for a card tap | | `112` | `preparing` | POS state message — reader is preparing for the transaction | | `106` | `readCompleted` | Card read completed successfully | ### Generic Failure (-1) A status code of `-1` indicates a generic failure. The SDK uses the `statusCodeDescription` field to provide more context: | Description Contains | Meaning | | -------------------------------- | -------------------------------------------- | | `"eligibility"` | Generic eligibility evaluation failure | | `"initialise"` or `"enrol"` | Generic initialization or enrollment failure | | `"integrity"` or `"attestation"` | Device integrity attestation failed | **Status code 12 (enrollment not done)** deserves special handling. The demo app treats `statusCode == 12` during `OnFailure` as retryable (alongside `42`, `53`, and a null status code) — it prompts "Please tap again" rather than surfacing an error. If the code persists, the Tap to Pay Ready app was most likely reinstalled or had its data cleared; the merchant app must then clear its stored enrollment data (`clearEnrollmentState()`) and re-run `enrollDevice()`. Re-enrollment is never automatic. ## Display Message IDs During the tap-to-pay flow, the SDK emits display messages via `displayMessage` on each `OnProgress` event. These correspond to standard EMV message identifiers from the kernel: | Message | ID | Description | | --------------------------------------- | ---- | -------------------------------------------------------------------- | | Approved | `03` | Authorization obtained — transaction approved | | Cancel or Enter | `05` | Prompt to cancel or confirm | | Card Error | `06` | Unrecoverable card data error | | Not Authorized / Declined | `07` | Transaction was declined by the issuer | | Please remove card | `10` | Card not yet removed from the reader field | | Please try again | `13` | Recoverable error — retry the tap | | Welcome | `14` | Idle state — reader is ready | | Present card | `15` | Prompt the cardholder to tap | | Processing | `16` | Transaction is being processed | | Card read OK / Remove card | `17` | Card was read successfully — may be removed | | Please insert or swipe card | `18` | Contactless not supported — try contact/mag-stripe | | Please present one card only | `19` | Card collision detected — present only one card | | Approved. Please Sign | `1A` | Approved; signature required | | Authorizing. Please Wait | `1B` | Online authorization in progress | | Insert, swipe, or try another card | `1C` | Contactless failed — use another interface or card | | Please insert card | `1D` | Chip card should be inserted into the slot | | _(Empty string)_ | `1E` | Clear the display | | See Phone for instructions | `20` | Mobile device CVM required (Touch ID, Face ID, etc.) | | Present card again | `21` | Recoverable error — present the card again | | Practice Mode | `40` | Successful test/practice transaction | | Partial Approval | `43` | Issuer approved a lesser amount | | Cancelled for Device Security | `46` | Transaction cancelled due to a device security issue | | Cancelled | `47` | Generic transaction cancellation | | Try another card - No contact interface | `48` | Card returned GPO error (SW 6984) — transaction aborted | | Strong CVM | `49` | SCA issuer response requires interface switch — transaction declined | ## Abort and Error Scenarios The SDK returns an `Abort` or `Failure` final status in several well-defined situations. Understanding these helps you build robust error handling: ### Transaction Abort Scenarios | Scenario | What Happens | Message ID | | ------------------------------- | ------------------------------------------------------------------------------- | ---------- | | **User cancels PIN entry** | User selects "Cancel Transaction" on the PIN keypad | — | | **PIN session timeout** | 1 minute of inactivity on the PIN keypad | — | | **PIN keypad interrupted** | Another app covers the PIN screen | — | | **Network loss during PIN** | Network drops before the PIN event is sent | — | | **Split screen mode** | Device enters split screen while on PIN screen — sends `CVEntrySecurity` cancel | — | | **Device security issue** | Security configuration problem detected | `46` | | **Generic cancellation** | User or system cancelled the transaction | `47` | | **Card NFC failure (GPO 6984)** | Card cannot complete contactless — abort with `MACompletion` indicator | `48` | | **Strong CVM / SCA switch** | Issuer requires contact interface (not supported) — decline with `MACompletion` | `49` | | **Developer mode enabled** | Developer options are on — reader blocks the transaction | — | ### OnFailure Status Codes When `actionStatus` is `OnFailure`, check `statusCode` for the specific reason: | Status Code Constant | Description | | ---------------------------------- | ------------------------------------------------------------------ | | `TRANSACTION_WINDOW_FOCUS_CHANGED` | The transaction window lost focus (another app came to foreground) | | `CAMERA_IS_ACTIVE` | Device camera is active — conflicts with the secure NFC session | | `DEVELOPER_MODE_ENABLED` | Developer options are enabled on the device | | `NFC_NOT_AVAILABLE` | Device NFC is disabled or unavailable | | `DEVICE_NOT_ENROLLED` | Device has not completed enrollment | | `SESSION_TIMEOUT` | The transaction session timed out | **Developer Mode**: The most common cause of unexpected transaction failures during development. Always disable developer mode before running transactions. Follow the workflow: **Enable dev mode → Install app → Disable dev mode → Run transactions**. ### Completion Indicators The receipt field `emv.tx.tm.CompletionIndicator` tells you how the transaction concluded at the kernel level: | Value | Meaning | | ---------------- | ----------------------------------------------------------------------------------------------------------------- | | `FullCompletion` | Transaction completed normally through the full authorization flow | | `MACompletion` | Transaction was terminated by the kernel (Merchant Application completion) — typically an abort or forced decline | ## Handling Responses in Practice The SDK uses two error channels. Here is a complete pattern for handling both: ### Channel 1: Transaction Flow (KoardTransactionResponse) For `sale()`, `preauth()`, `completePartialAuth()`, and tap-based refunds, use the complete [reader response handler in Running Payments](/docs/setting-up-the-android-sdk/running-payments#launching-a-sale-session). Keep collecting the same flow for retryable read hints with no final status; do not start another payment. Classify completion by the returned transaction status as well as the nullable `finalStatus`, handling partial approval and surcharge decisions before a completed receipt. A flow that ends without a terminal event needs outcome recovery. ### Channel 2: API & SDK Operations (KoardException) For `capture()`, `reverse()`, `refund()`, `adjust()`, `enrollDevice()`, `setActiveLocation()`, and other non-tap operations — errors are thrown as `KoardException`: ```kotlin private suspend fun capturePayment(transactionId: String, amount: Int) { try { val transaction = withContext(Dispatchers.IO) { sdk.capture(transactionId, amount).getOrThrow() } when (transaction.status) { KoardTransactionStatus.CAPTURED, KoardTransactionStatus.SETTLED -> showReceipt(transaction) else -> reconcileTransaction(transaction) } } catch (e: CancellationException) { throw e } catch (e: KoardException) { when (val errorType = e.error.errorType) { // HTTP errors from the Koard API is KoardErrorType.KoardServiceErrorType.HttpError -> showError("Server error (HTTP ${errorType.errorCode}): ${e.error.shortMessage}") is KoardErrorType.KoardServiceErrorType.ConnectionError -> showError("Network error; check your connection and reconcile the capture outcome") is KoardErrorType.KoardServiceErrorType.Unauthorized -> showError("Session expired — please log in again") is KoardErrorType.KoardServiceErrorType.NotFound -> showError("Transaction not found") is KoardErrorType.KoardServiceErrorType.InvalidRequest -> showError("Invalid request: ${e.error.shortMessage}") // Device integrity failures is KoardErrorType.DeviceIntegrityError.DeveloperModeEnabled -> showError("Disable developer mode before processing payments") is KoardErrorType.DeviceIntegrityError -> showError("Device security check failed: ${e.error.shortMessage}") // Enrollment issues is KoardErrorType.VACEnrollmentError -> promptReEnrollment(e.error.shortMessage) is KoardErrorType.VACEligibilityError -> showError("Device not eligible for Tap to Pay: ${e.error.shortMessage}") // KiC connector/kernel errors is KoardErrorType.KicConnectorError.KernelAppNotInstalled -> showError("Install the Visa Tap to Pay Ready app from Google Play") is KoardErrorType.KicConnectorError -> showError("Kernel communication error: ${e.error.shortMessage}") is KoardErrorType.KicPrepareError.SdkEnrollNotDone -> promptReEnrollment("Device needs re-enrollment") // Fallback else -> showError(e.error.shortMessage) } } } ``` ## Next Steps * Review the [Running Payments](/docs/setting-up-the-android-sdk/running-payments) guide for the complete payment flow implementation * See the [Demo App](/docs/setting-up-the-android-sdk/demo) for a working example of response handling in `MainScreenViewModel` * Consult the [API Response Codes](/api-reference/response-codes) for HTTP-level status codes from the Koard REST API # Supported Devices & NFC Tap Location A guide to compatible Android devices for [Tap to Pay](/docs/appendix/glossary#tap-to-pay-android "Contactless acceptance on NFC Android devices using the Visa Tap to Pay Ready kernel."), device requirements, and where customers should tap their card on each device. **What you learn** In this guide, you'll learn: * Which Android devices are compatible with Tap to Pay * Minimum device requirements for NFC contactless payments * Where the NFC antenna is located on popular devices * How to instruct customers to tap their card correctly ## Device Requirements To accept Tap to Pay on Android, the merchant's device must meet **all** of the following requirements: | Requirement | Details | | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Android version** | Android 13 (API level 33) or later | | **NFC hardware** | Built-in NFC chip (virtually all modern flagship and mid-range Android phones) | | **Hardware keystore** | TEE or StrongBox-backed keystore for secure key storage | | **Google Play Services** | Google Mobile Services (GMS) must be installed and up to date | | **Google Play Protect** | Must be enabled for device integrity verification | | **Developer mode** | Must be **disabled** during live transactions | | **Device integrity** | Passes Play Integrity: release-signed APK, locked bootloader, no root/Magisk. No custom ROMs | | **Visa Kernel app** | Visa Tap to Pay Ready **25.06.10 or later** must be installed, either directly from Google Play or through the optional in-app flow — see [installation options](/docs/setting-up-the-android-sdk/installing-the-sdk#step-5-install-visa-tap-to-pay-ready) | **Developer Mode**: Tap to Pay transactions will fail if developer mode is enabled. Always disable developer mode before processing payments. See the [Installing the SDK](/docs/setting-up-the-android-sdk/installing-the-sdk) guide for the recommended workflow. ### Play Integrity Gating In addition to the hardware/software requirements above, the underlying contactless engine enforces **Play Integrity** at enrollment time. The device must be running a **release-signed APK on a locked bootloader**, with **no root/Magisk** and **Developer Options turned off**. Debug-signed builds installed via Run ▶ from Android Studio are rejected during enrollment, even on otherwise-eligible hardware. ### Eligibility Check The SDK provides a built-in eligibility check that verifies device requirements before attempting a transaction. Call `checkKiCEligibility()` on a worker thread; it returns a `KoardKiCEligibility` with an `isEligible` flag and the set of `failureCodes` when the device is not eligible: ```kotlin withContext(Dispatchers.IO) { val sdk = KoardMerchantSdk.getInstance() val eligibility = sdk.checkKiCEligibility() if (eligibility.isEligible) { // Device is eligible for Tap to Pay } else { // Handle ineligibility — inspect failure codes eligibility.failureCodes.forEach { code -> Log.w("TapToPay", "Eligibility failure code: $code") } } } ``` The numeric `failureCodes` map to the eligibility error codes documented in [SDK Response Codes](/docs/setting-up-the-android-sdk/sdk-response-codes#kic-eligibility-errors). ### Visa Kernel App Tap to Pay requires Visa Tap to Pay Ready on the device. Merchants can install it directly from Google Play or use an optional in-app Install action. The [Installing the SDK guide](/docs/setting-up-the-android-sdk/installing-the-sdk#step-5-install-visa-tap-to-pay-ready) covers both options and how to confirm installation before enrollment. ## Tested & Certified Devices The following devices have been tested and certified by Visa for use with the Kernel in the Cloud (KiC) Tap to Pay Ready application: | Device | Android Version | Form Factor | | ------------------ | --------------- | ----------- | | Google Pixel 4 | Android 13 | Phone | | Google Pixel 6 | Android 15 | Phone | | Google Pixel 8 | Android 16 | Phone | | Google Pixel 9 | Android 15 | Phone | | Samsung Galaxy S22 | Android 13 | Phone | | Samsung Galaxy S23 | Android 14 | Phone | | Oona Tablet | — | Tablet | **Not limited to this list**: These are the devices Visa has explicitly tested and certified. In practice, **any Android device** that meets all the requirements listed above (Android 13+, NFC, hardware keystore, GMS) should work with Tap to Pay. The SDK's `checkKiCEligibility()` check will confirm compatibility at runtime. ### Full Supported Device List Beyond the Visa-certified list, Tap to Pay on Android is supported across a wide range of manufacturers and models. The following devices are confirmed compatible (when running Android 13+): | Brand | Supported Models | | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **Asus** | Zenfone 9, Zenfone 10, Zenfone 11 Ultra, Zenfone 12 Ultra, ROG Phone 6, ROG Phone 7, ROG Phone 8, ROG Phone 9 | | **Google Pixel** | Pixel 6, Pixel 6a, Pixel 7, Pixel 7a, Pixel 8, Pixel 8a, Pixel 9, Pixel 9a, Pixel 10 | | **Nokia** | G22, G42, G60, G400, X30, XR21 | | **Honor** | 70, 70 Lite, 80, 90, 90 Lite, Magic5, Magic6, Magic7, Magic8 | | **Infinix** | Hot 30, Hot 40, Hot 50, Hot 60i, Zero 20, Zero 30, Zero 40 | | **Motorola** | Edge 2023, Edge 2024, Edge 2025, Moto G 2025, Razr 40, Razr 50, Razr 60 | | **OnePlus** | Nord 3, Nord 4, Nord 5, Nord CE3, Nord CE4, Nord CE5, Nord N30, 11, 11R, 12, 12R, 13, 13R | | **Oppo** | A60, A77, A78, A98, Find X5, Find X6, Find X7, Find X8, Find X9, Reno8, Reno9, Reno10, Reno11, Reno12, Reno13, Reno14, Reno15 | | **Samsung Galaxy** | A04s, A05s, A13, A14, A15, A16, A17, A24, A25, A26, A33, A34, A35, A36, A53, A54, A55, A56, A73, S22, S23, S24, S25, S26 Ultra, Z Flip4, Z Flip5, Z Flip6, Z Flip7, Z Fold4, Z Fold5, Z Fold6, Z Fold7 | | **Xiaomi** | 12, 12S, 12T, 13, 13T, 14, 14T, 15, 15T, Redmi 12, Redmi 12C, Redmi 13, Redmi 13C, Redmi 14C, Redmi 15, Redmi Note 12, Redmi Note 13, Redmi Note 14 | **Always verify at runtime**: NFC support can vary by region and carrier variant — especially for mid-range devices. Use the SDK's `checkKiCEligibility()` check at runtime rather than relying solely on a static device list. ## NFC Antenna Location & Tap Guidance The NFC antenna location determines where the customer should hold or tap their contactless card or device. Getting this right is critical for a smooth payment experience. ### General Rule On virtually all Android phones, the **NFC antenna is located on the back of the device, in the upper-center area** (roughly behind the rear camera module). Customers should hold their card flat against the **upper-middle portion of the phone's back**. ### NFC Antenna Location Summary The table below summarizes NFC antenna placement by manufacturer. For per-model details, see the device tables in the [Full Supported Device List](#full-supported-device-list) above. | Manufacturer | NFC Antenna Location | Tap Zone Guidance | | ------------------------------ | ------------------------------------------------------- | -------------------------------------------------- | | **Asus** (Zenfone / ROG Phone) | Center to upper-center back | Hold card against the center-top third of the back | | **Google Pixel** | Upper-center back, near/behind the rear camera bar | Hold card against the top third of the back | | **Nokia** | Upper-center back | Hold card against the top third of the back | | **Honor** | Upper-center back; Magic series slightly above midpoint | Hold card against the top third of the back | | **Infinix** | Upper-center back | Hold card against the top third of the back | | **Motorola** (Edge / Moto G) | Upper-center back, near the camera or Motorola logo | Hold card against the top third of the back | | **Motorola** (Razr foldables) | Upper half of back (when folded) | Tap on the upper portion of the folded device | | **OnePlus** (flagships) | Upper-center back, near camera module | Hold card against the top third of the back | | **OnePlus** (Nord series) | Upper-center back | Hold card against the top third of the back | | **Oppo** (Find X series) | Center back, near camera module | Hold card against the center of the back | | **Oppo** (A / Reno series) | Upper-center back | Hold card against the top third of the back | | **Samsung Galaxy S** series | Center back, slightly above the midpoint | Hold card against the center of the back | | **Samsung Galaxy A** series | Upper-center to center back (varies by tier) | Hold card against the center of the back | | **Samsung Galaxy Z Flip** | Upper half of the back (when folded) | Tap on the upper portion of the folded device | | **Samsung Galaxy Z Fold** | Center of the back panel (when closed) | Tap on the center of the back when folded | | **Xiaomi** (flagships) | Upper-center back, near camera module | Hold card against the top third of the back | | **Xiaomi** (Redmi series) | Upper-center back | Hold card against the top third of the back | | **Tablets** | Varies — typically center back or near one edge | Check manufacturer documentation | ### Visual Tap Guide For the best tap experience, instruct customers to: 1. **Remove the card from any wallet or sleeve** — Other cards or RFID-blocking material can interfere with the NFC signal 2. **Hold the card flat** against the back of the phone — Do not tap at an angle 3. **Position the card** over the NFC antenna zone (upper-center back on most devices) 4. **Hold steady for 1–2 seconds** — Do not pull away until the phone confirms the read 5. **Listen/watch for confirmation** — The device will display a status message and/or vibrate when the card is read successfully ```plaintext ┌─────────────────────┐ │ │ ← Phone (back view) │ ┌───────────┐ │ │ │ 📷 Camera │ │ │ └───────────┘ │ │ ╔═══════════════╗ │ │ ║ NFC ANTENNA ║ │ ← Tap card here │ ║ TAP ZONE ║ │ │ ╚═══════════════╝ │ │ │ │ │ │ │ │ │ └─────────────────────┘ ``` **Phone cases**: Thin phone cases generally do not interfere with NFC reads. However, thick rugged cases, metal cases, or cases with built-in card holders may block or weaken the NFC signal. If customers experience read failures, try removing the case. ### Handling Tap Failures If the card does not read on the first attempt: 1. Reposition the card slightly — move it toward the camera area 2. Ensure the card is flat and not angled 3. Remove any phone case that may be interfering 4. Check that the device screen shows the "Present card" or "Tap card" prompt 5. If the issue persists, the SDK will surface an appropriate [display message](/docs/setting-up-the-android-sdk/sdk-response-codes#display-message-ids) or an `OnFailure` status code such as `NFC_NOT_AVAILABLE` ## Troubleshooting Device Compatibility | Issue | Possible Cause | Solution | | ------------------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Eligibility check returns failure codes | Device doesn't meet one or more requirements | Inspect the failure codes from `checkKiCEligibility()` and address each (e.g., enable Google Play Protect, update Google Play Services, disable developer mode) | | NFC transactions fail on a supported device | Developer mode enabled | Disable developer mode in Settings → Developer options | | Card not reading | NFC disabled in device settings | Go to Settings → Connected devices → Connection preferences → NFC and ensure it's toggled on | | Intermittent read failures | Card positioned incorrectly | Guide the customer to tap in the correct NFC zone (see table above) | | "Kernel app not found" error | Visa Tap to Pay Ready app not installed | Install directly from Google Play or through the optional in-app flow; see [installation options](/docs/setting-up-the-android-sdk/installing-the-sdk#step-5-install-visa-tap-to-pay-ready) | | Transactions fail after app update | Developer mode was re-enabled for deployment | Disable developer mode after installing updates, then restart the device | ## Next Steps * [Installing the SDK](/docs/setting-up-the-android-sdk/installing-the-sdk) — Set up your development environment * [Running Payments](/docs/setting-up-the-android-sdk/running-payments) — Process your first Tap to Pay transaction * [SDK Response Codes](/docs/setting-up-the-android-sdk/sdk-response-codes) — Understand transaction outcomes and error handling * [Running the Demo App](/docs/setting-up-the-android-sdk/demo) — Test with the sample application # Troubleshooting Real-world fixes for issues merchants commonly hit when enrolling devices or running their first tap. If a device fails the SDK eligibility check, see [Supported Devices & NFC Tap Location](/docs/setting-up-the-android-sdk/supported-devices). For SDK error codes returned during a transaction, see [SDK Response Codes](/docs/setting-up-the-android-sdk/sdk-response-codes). **What you learn** In this guide, you'll learn: * How to fix the "tap was immediately cancelled" issue * How to fix "Kernel App is not installed" when the kernel app is installed * How to resolve enrollment failures caused by device clock drift * How to recover from a `TamperDetected` error on every sale after a failed enrollment * What to do when the kernel is busy with another merchant app * How to fix the "SDK not initialized" error * Which device settings must be enabled before a merchant's first transaction * A pre-flight checklist to run before contacting Koard support ## Tap is Immediately Cancelled ### Symptom The merchant enrolls successfully and the SDK reports the device as eligible. When they attempt their first sale, the reader shows "Present card", but the moment the customer taps, the transaction is **immediately cancelled** — the SDK emits an `Abort` (or `OnFailure`) before the card is read. This has been observed across several Android devices, with **Samsung Galaxy S21** being the most common — but any Android device that exposes a separate **"NFC and contactless payments"** (or similarly named) toggle can hit it. ### Cause Many Android devices ship with **two distinct NFC settings**: 1. **NFC** — the basic NFC radio toggle (enabled by default on most devices). 2. **NFC and contactless payments** — a separate setting that authorizes the device to use NFC for **payment-related** Host Card Emulation (HCE) and [Tap to Pay](/docs/appendix/glossary#tap-to-pay-android "Contactless acceptance on NFC Android devices using the Visa Tap to Pay Ready kernel.") flows. Different OEMs label this differently (e.g. "NFC and contactless payments", "NFC and contactless transactions", "Contactless payments"). The SDK eligibility check verifies that NFC hardware is present and the radio is on, which is why enrollment and the eligibility check both pass. However, if the secondary **"NFC and contactless payments"** setting is disabled, the Visa Tap to Pay Ready kernel cannot complete the contactless card read — the device's payment subsystem blocks the read and the transaction is cancelled before any card data is exchanged. ### Fix On the merchant's device: 1. Open **Settings** 2. Tap **Connections** (or **Connected devices** depending on the OEM and Android version) 3. Tap **NFC and contactless payments** (sometimes labeled **NFC and contactless transactions** or simply **Contactless payments**) 4. Ensure the master toggle is **On** 5. Optionally set **Contactless payments → Default payment app** to your merchant app, or leave it as the system default if your app does not require the system payment role After enabling the setting, retry the sale. No re-enrollment is required. **Why the eligibility check doesn't catch this**: `checkKiCEligibility()` verifies that NFC hardware exists and the system NFC radio is enabled. The OEM-specific "contactless payments" toggle gates a higher layer (the payment HCE subsystem) and is not visible to the eligibility API. If a merchant sees taps cancelled instantly, always confirm this setting before deeper debugging. If "NFC and contactless payments" is enabled and the tap is still cancelled instantly, check whether a wallet app (Samsung Wallet, Google Wallet, etc.) is set as the default and is intercepting the tap. Temporarily clear the default payment app under **NFC and contactless payments → Contactless payments**, then retry. ## "Kernel App is not installed" When It Is Installed ### Symptom Enrollment fails with `IllegalStateException: Kernel App is not installed`, and the SDK readiness log shows `kernelApp=false`, even though the Visa Tap to Pay Ready kernel app (`com.visa.kic.app.kernel`) is installed and up to date on the device. Reinstalling the kernel app does not help. ### Cause Android 11 and later only let an app see other installed apps that its manifest declares. This is called package visibility. The Koard SDK finds the kernel by checking that `com.visa.kic.app.kernel` is installed, and then binds to the kernel's service to run every tap. Without a declaration, Android hides the kernel from your app, so the SDK reports it as missing. The Koard SDK does not contribute this declaration. Your app's manifest must include it. ### Fix Add a `` element to your app's `AndroidManifest.xml`, directly inside ``: ```xml ``` Rebuild and reinstall the app, then run enrollment again. No change to the kernel app is needed. See [Step 2: Configure AndroidManifest.xml Permissions](/docs/setting-up-the-android-sdk/installing-the-sdk) for the complete manifest. **What the declaration grants**: visibility of exactly one package, the Visa kernel. It does not let your app see or query any other installed app, and it needs no permission prompt. It is narrower than the `QUERY_ALL_PACKAGES` permission, which Google Play restricts and which Tap to Pay does not need. ## Enrollment Fails or Returns Errors ### Symptom Calls to `enrollDevice()` (or the SDK's enrollment flow) fail with errors such as: * `InvalidRequest` — "Developer mode is enabled", "Device already enrolled", or no active location was set. `enrollDevice()` requires an authenticated session **and** a prior `setActiveLocation(locationId)` call * `VACEnrollmentError` * `AuthenticationFailed` (status code `7`) * `AttestationFailed` (status code `24`) * `CouldNotAttestError` * Generic prepare errors in the `7`–`51` range The merchant sees an "Enrollment failed" screen and cannot proceed to take their first tap. ### Cause Enrollment establishes a secure channel between the device and the Visa Acceptance Cloud (VAC). That handshake includes a **device attestation step** that is sensitive to three things: 1. **Developer mode is enabled** — most enrollment endpoints reject devices with developer options on, because the device cannot produce a trustworthy attestation. 2. **The device clock has drifted** — VAC verifies signed timestamps during the handshake. If the device clock is off by more than a few minutes (a "clock drift" issue), signatures fail to verify and attestation is rejected. This is most common on devices that have been offline for a long time, recently factory-reset, or have automatic time disabled. 3. **Google Play Protect is disabled** — Play Protect provides the integrity signals VAC uses to attest the device. With Play Protect off, the attestation payload is incomplete and enrollment is rejected. ### Fix Walk the merchant through all three checks before retrying enrollment: #### 1. Disable Developer Mode 1. Open **Settings → System → Developer options** 2. Toggle **Developer options** to **Off** 3. If the option is missing entirely, developer mode is already off — proceed to the next step If developer mode was on, restart the device after disabling it. #### 2. Enable Automatic Date & Time 1. Open **Settings → General management → Date and time** (Samsung) or **Settings → System → Date & time** (stock Android) 2. Toggle **Automatic date and time** to **On** 3. Toggle **Automatic time zone** to **On** 4. Wait a few seconds for the device to sync with the network time source This corrects clock drift and is the most common fix when enrollment fails on a device that previously worked. #### 3. Enable Google Play Protect 1. Open the **Google Play Store** app 2. Tap your profile icon (top right) → **Play Protect** 3. Tap the **gear icon** (settings) in the top right of the Play Protect screen 4. Toggle **Scan apps with Play Protect** to **On** Play Protect must be enabled for the device attestation step to succeed. If the merchant cannot enable Play Protect (for example on a device without Google Mobile Services), the device is not eligible for Tap to Pay. After all three checks pass, restart the device and retry enrollment. If the SDK still returns an enrollment error, capture the `statusCode` and `errorType` and consult the [SDK Response Codes](/docs/setting-up-the-android-sdk/sdk-response-codes#kic-prepare-errors) reference. **Why clock drift matters**: The VAC attestation handshake relies on signed timestamps to prevent replay attacks. The SDK's enrollment flow includes a fresh nonce and a device-side timestamp; if the device clock is more than a few minutes off the server clock, the server rejects the timestamp and the handshake fails — often surfacing as a generic `AuthenticationFailed` (code `7`) or `AttestationFailed` (code `24`). Enabling automatic time syncs the device against a network time source and eliminates the drift. ## `TamperDetected` on Every Sale After a Failed Enrollment ### Symptom `enrollDevice()` (or `enableNfcTransactionsAsync(...)`) appears to finish but reports a non-success status. Afterwards, **every** sale throws `KoardErrorType.DeviceIntegrityError.TamperDetected` (status code `1002`). Clearing the app's data via Android Settings makes the next enrollment succeed — which points at corrupted local state rather than a genuinely tampered device. ### Cause A partial enrollment can leave the SDK's local preferences (certificates, VAC device ID, auth keys, x-via hint, x-random value) half-written and out of sync with the kernel's internal state. The mismatch surfaces as a false `TamperDetected` on the next transaction. ### Fix Call `clearEnrollmentState()` from a worker thread to wipe the local enrollment preferences, then re-attempt enrollment. The active location is intentionally preserved, and this is a local-only operation — it does **not** contact the backend. ```kotlin lifecycleScope.launch(Dispatchers.IO) { val sdk = KoardMerchantSdk.getInstance() sdk.clearEnrollmentState() sdk.enrollDevice() // or enableNfcTransactionsAsync(...) } ``` Use `clearEnrollmentState()` for half-enrolled recovery. If the device is **fully** enrolled, call `unenrollDevice()` instead: it asks the Tap to Pay Ready app to clean up the enrollment data it holds for your app, then clears the SDK's local state. Local state is flushed either way — if the kernel call fails, the returned string reports the failure but the device can still re-enroll.\ \ **This does not fully deprovision the device on Visa's backend.** Per KiC integration guide §3.4.20 (implementation _optional_), `unenrollDevice()` only cleans up enrollment data on the Tap to Pay Ready app; Visa requires the integrator to _also_ call the backend `manageDevice` API to disable the device, otherwise it keeps being billed. Coordinate that step with Koard support. ## Tap to Pay Is Busy with Another Merchant App ### Symptom A sale fails with `KoardErrorType.KicConnectorError.KernelAppBusyWithAnotherMerchant` (status code `98`). ### Cause Introduced with KiC multi-tenancy, this means a **different** merchant app on the device currently holds the lock on the Visa Tap to Pay Ready kernel service. ### Fix Ask the operator to close the other merchant app (or wait for it to finish its transaction), then retry. Calling `resetKernelService()` will **not** resolve this — the lock is owned by a different process, not your app. ## "SDK Not Initialized" Error ### Symptom ```text IllegalStateException: Instance is null. Did you forget to call initialize? ``` is thrown the first time you call `KoardMerchantSdk.getInstance()`. ### Cause `getInstance()` was called before `KoardMerchantSdk.initialize(...)` completed. ### Fix Initialize the SDK in `Application.onCreate()` on a worker thread (`Dispatchers.IO`) before any code accesses `getInstance()`. See the [Installing the SDK](/docs/setting-up-the-android-sdk/installing-the-sdk) guide for the recommended startup sequence. ## Pre-Flight Checklist Before contacting Koard support about a device that "won't take a tap" or "fails to enroll", confirm **all** of the following on the merchant's device: * Device is running **Android 13 (API 33) or later** * **NFC** is enabled in Settings * **NFC and contactless payments** is enabled (where present) * **Developer mode** is **off** * **Automatic date and time** is **on** * **Google Play Protect** is enabled * **Google Play Services** is installed and up to date * **Visa Tap to Pay Ready** is installed, either directly from Google Play or through the optional in-app flow — see [installation options](/docs/setting-up-the-android-sdk/installing-the-sdk#step-5-install-visa-tap-to-pay-ready) * Device is not rooted and is not running a custom ROM * `checkKiCEligibility()` returns no failures If every item is checked and the device still fails, gather the following before contacting support: * Device make, model, and Android version * **Koard SDK version** (the version of `koard-android` / the Koard Merchant SDK your app is built against) * The `statusCode` and `statusCodeDescription` from the failing `KoardTransactionResponse`, or the `KoardErrorType` from the thrown `KoardException` * A log excerpt from the time of the failure. The SDK logs to Logcat under the fixed tag **`KoardSDK`**; capture it with `adb logcat -s KoardSDK`. Verbosity is set once at startup via `KoardMerchantSdk.initialize(..., logLevel = KoardLogLevel.VERBOSE)` — there is no runtime setter, and the default is `NONE` in release builds, so ask the merchant for a build that opts in. The SDK never logs [API keys](/docs/appendix/glossary#api-key "Secret header credential for REST calls; v5 keys use scoped permissions."), tokens, device certificates, or enrollment key material at any level. # Demo ## Setup To run the demo app, you need to get the project and configure your API credentials: **1. Get the Demo Project** * Clone the Git repository and select the 1.0.7 release branch: `git clone --branch release/1.0.7 https://github.com/koardlabs/koard-android.git` * Or [download the ZIP file](https://github.com/koardlabs/koard-android/archive/refs/heads/release/1.0.7.zip) **2. Open the Project in Android Studio** * Open Android Studio * Select **File > Open** * Navigate to the `koard-android` folder and open it * Wait for Gradle sync to complete The demo app is the root Gradle module. Use the published SDK from **Maven Central** as `com.koard:koard-android-sdk:1.0.7`; no SDK source module, GitHub Packages token, or manually copied AAR is needed. The release branch also carries a local mirror. To resolve from Central, remove its `exclusiveContent` block for `KoardReleaseMirror` in `settings.gradle.kts`, leaving these dependency repositories: ```kotlin dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { google() mavenCentral() } } ``` Keep the separate `pluginManagement` repositories and the demo's other Gradle settings. In `build.gradle.kts`, use `implementation("com.koard:koard-android-sdk:1.0.7")`. **3. Configure Your Credentials** Open `build.gradle.kts` and set your **[API key](/docs/appendix/glossary#api-key "Secret header credential for REST calls; v5 keys use scoped permissions.")** for each build flavor via the `API_KEY` `buildConfigField`. The flavor's `ENVIRONMENT` field selects the matching `KoardEnvironment` at startup: ```kotlin android { flavorDimensions += "environment" productFlavors { create("uat") { dimension = "environment" isDefault = true buildConfigField("String", "ENVIRONMENT", "\"UAT\"") buildConfigField("String", "API_KEY", "\"YOUR_API_KEY\"") applicationIdSuffix = ".uat" } create("prod") { dimension = "environment" buildConfigField("String", "ENVIRONMENT", "\"PROD\"") buildConfigField("String", "API_KEY", "\"YOUR_API_KEY\"") } } } ``` Replace `YOUR_API_KEY` with your Koard API key. The merchant **code** and **PIN** are not build config values — you enter them on the demo's login screen at runtime, where they are passed to `sdk.login(merchantCode, merchantPin)`. The active location is selected in-app after login. **Important**: For production use, externalize these credentials using secure storage or environment variables. Never commit actual credentials to version control. **4. Select Build Flavor** The demo app has two build flavors: | Flavor | API Environment | Application ID Suffix | | ------ | -------------------------------------------------------------------------------------------------------------------------- | --------------------- | | uat | [UAT](/docs/appendix/glossary#uat "Isolated sandbox environment — transactions do not move live money.")/Testing (default) | `.uat` | | prod | Production | (none) | In Android Studio: * Go to **Build > Select Build Variant** * Choose your desired flavor (e.g., `uatDebug` for testing) There is no `dev` flavor. To point the demo at a development backend, switch the SDK to `KoardEnvironment.Custom(...)` in `DemoApplication`. **5. Build the Project** Build the project using one of these methods: **Via Android Studio:** * Click **Build > Make Project** (⌘+F9 / Ctrl+F9) **Via Command Line:** ```bash # Build UAT flavor ./gradlew assembleUatDebug # Build production flavor ./gradlew assembleProdRelease ``` **6. Install on Physical Device** **Physical Device Required**: [Tap to Pay](/docs/appendix/glossary#tap-to-pay-android "Contactless acceptance on NFC Android devices using the Visa Tap to Pay Ready kernel.") on Android requires a physical device with NFC hardware. The emulator does not support NFC payments. **Prerequisites:** * Physical Android device with NFC support (Android 13+) * Tap-to-pay kernel app installed on the device (see the Running Payments guide) * USB debugging enabled on the device **Install the app:** **Via Android Studio:** * Connect your device via USB * Click **Run > Run 'app'** (Shift+F10) **Via Command Line:** ```bash # Install UAT build ./gradlew installUatDebug # Install production build ./gradlew installProdRelease ``` ## Running the Demo Disable Developer Mode **IMPORTANT: Turn Off Developer Mode** After installing or updating the app, disable developer mode **before enrollment, location changes on an enrolled device, or any tap-to-pay transaction**: 1. Go to **Settings > System > Developer Options** 2. Toggle **Developer Mode** to OFF 3. Restart your device (recommended) The SDK rejects enrollment and tap-to-pay transactions while developer mode is enabled. After disabling it, return to the demo and let its readiness checks refresh. **Developer Workflow:** * **Enable Developer Mode** → Install/update app → **Disable Developer Mode** → Log in → Select location → Enroll → Run transactions Launch the App Open the Koard Demo app on your device. You should see the main screen. Authenticate Merchant Enter your merchant **code** and **PIN** on the login screen — these are entered at runtime, not baked into the build — and submit. The demo calls `sdk.login(merchantCode, merchantPin)`. You will know authentication was successful if you see: * The app navigates past the login screen * The location selector and enrollment panel become available Select Location Tap the location selector in the top bar and choose your active location. The demo calls `sdk.setActiveLocation(location.id)` with your choice. The selected location is used for all transactions, and **enrollment cannot proceed without it**. Enroll the Device **Enrollment is not automatic.** Nothing enrolls as a side effect of logging in or picking a location — you must press the button. Until enrollment completes, the demo's home screen shows the enrollment panel instead of the transaction form, and any tap API would throw `KoardErrorType.NotReady.NotEnrolled`. While `readinessState.enrollmentState` is anything other than `Enrolled`, the demo's home screen renders an enrollment panel in place of the transaction form. Press **Enroll Device** to run `sdk.enrollDevice()`. Under the hood the demo: 1. Checks the tap-to-pay kernel app is installed via `sdk.isKernelAppInstalled()`, and surfaces "Visa Kernel app is not installed." if it isn't 2. Calls `sdk.enrollDevice()`, which generates device certificates and enrolls the device with Koard's payment services 3. Treats the returned `String` as an error message if it contains "failed" or "error", and catches `KoardException` for typed failures You will know enrollment was successful if you see: * The enrollment panel is replaced by the transaction form * `readinessState.enrollmentState` becomes `Enrolled` and the reader status banner clears * NFC transactions can be initiated Enrollment is rejected outright if developer mode is on, if no active location is set, or if the device is already enrolled. To re-enroll, use **Unenroll Device** on the Settings screen (which calls `sdk.unenrollDevice()`) first. Process Sample Transaction Test a contactless payment: 1. Tap **Process Transaction** or **New Payment** 2. Enter a sample dollar amount (e.g., $10.00) 3. Select transaction type (Sale or [Preauth](/docs/appendix/glossary#preauth "Authorization hold without capture; must later capture or reverse.")) 4. Tap **Start** or **Continue** Complete Tap to Pay Transaction Complete the payment flow: 1. Prompt the customer to tap their card or phone 2. Hold the card/phone against the device's NFC reader 3. Wait for the transaction to process 4. View the transaction result (approved/declined) Transaction details will include: * Transaction ID * Authorization code * Card type and last 4 digits * Amount charged * Transaction status View Transaction History Navigate to **Transaction History** to: * View all completed transactions * Filter by date or status * View detailed transaction information * Send receipts via email or SMS ## Demo App Features The demo app showcases the following SDK capabilities: ### Merchant Authentication * Login with merchant code and PIN * Session management * Automatic token refresh ### Device Enrollment * NFC configuration with Koard's tap-to-pay services * Certificate management * Device provisioning status ### Location Management * Retrieve all merchant locations * Select active location * Location-based transaction processing ### Transaction Processing * Sale transactions (immediate capture) * Preauthorization (auth hold) * Capture (settle preauth) * Refund processing * Transaction reversal * Tip adjustment ### Transaction History * List all transactions * View transaction details * Filter and search * Transaction status tracking ### Digital Receipts * Send receipts via email * Send receipts via SMS * Receipt customization ## Project Structure The demo app structure: ```plaintext koard-android/ ├── src/ │ └── main/ │ ├── java/com/koard/android/ │ │ ├── DemoApplication.kt # SDK initialization │ │ ├── MainActivity.kt # NFC register/unregister in onResume/onPause │ │ ├── navigation/ # Tab navigation (History, Home, Settings) │ │ └── ui/ │ │ ├── LoginScreen.kt / LoginViewModel.kt │ │ ├── MainScreen.kt / MainScreenViewModel.kt │ │ ├── SettingsScreen.kt │ │ ├── TransactionDetailsScreen.kt │ │ └── TransactionHistoryScreen.kt │ ├── res/ │ │ └── xml/nfc_tech_filter.xml # IsoDep tech filter for TECH_DISCOVERED │ └── AndroidManifest.xml ├── build.gradle.kts # Build configuration + SDK coordinate ├── settings.gradle.kts # Google + Maven Central dependency repositories └── README.md ``` ### SDK Integration The demo uses the released Maven Central artifact after the repository setup above: ```kotlin // build.gradle.kts implementation("com.koard:koard-android-sdk:1.0.7") ``` The published artifact is a **shaded fat AAR**: it bundles the tap-to-pay engine (KiC) and relocates OkHttp, Okio, Retrofit, kotlinx.serialization, and Ktor under `com.koardlabs.*`, so those never clash with your app's own versions. It also means they are **not** transitive dependencies — see [Installing the SDK](/docs/setting-up-the-android-sdk/installing-the-sdk) for what you must declare yourself. The environment (UAT vs. PROD) is chosen at runtime from the flavor's `ENVIRONMENT` `buildConfigField`, not by swapping artifacts: * **UAT builds** → `KoardEnvironment.UAT` * **Prod builds** → `KoardEnvironment.PROD` ### How the Demo Initializes the SDK `DemoApplication.onCreate()` maps `BuildConfig.ENVIRONMENT` to a `KoardEnvironment` and initializes the SDK on a worker thread, passing the API key from `BuildConfig`: ```kotlin val environment = when (BuildConfig.ENVIRONMENT) { "PROD" -> KoardEnvironment.PROD "UAT" -> KoardEnvironment.UAT else -> KoardEnvironment.UAT } KoardMerchantSdk.initialize( application = this@DemoApplication, apiKey = BuildConfig.API_KEY, environment = environment, timeoutSeconds = 30L, logLevel = KoardLogLevel.NONE ) ``` Merchant credentials are supplied later, on the login screen, via `sdk.login(merchantCode, merchantPin)`. To reduce first-tap latency, warm the reader after merchant login, active-location selection, and enrollment by collecting `sdk.prepare()` on a worker thread. Watch for `KoardPrepareStatus.Done` and handle preparation errors. See [Running Payments](/docs/setting-up-the-android-sdk/running-payments) for details. ## Build Commands Reference ```bash # Clean build ./gradlew clean # Build all flavors ./gradlew build # Build specific flavor ./gradlew assembleUatDebug ./gradlew assembleProdRelease # Install on device ./gradlew installUatDebug ./gradlew installProdRelease # Uninstall from device ./gradlew uninstallUatDebug ./gradlew uninstallProd # Run tests ./gradlew test # Generate APK ./gradlew assembleDebug ./gradlew assembleRelease ``` ## Troubleshooting ### Common Issues **App crashes on startup** **Solution**: * Verify credentials are configured correctly in `build.gradle.kts` * Check that you're not using placeholder values (YOUR\_API\_KEY\_HERE) * Review Logcat for specific error messages **"Device not provisioned" error** **Solution**: * Ensure the tap-to-pay kernel app is installed (see the Running Payments guide for setup steps) * Confirm you selected a location and then pressed **Enroll Device** — enrollment never runs on its own * Check NFC hardware is functioning * Verify the kernel app is up to date **NFC not working** **Solution**: * Confirm device has NFC hardware * Enable NFC in device settings * Verify device enrollment was successful * Test with a physical payment card * **IMPORTANT**: Ensure developer mode is DISABLED on the device **"Transaction failed" or "NFC error" during tap to pay** **Solution**: * **Disable developer mode** - This is the most common cause of transaction failures * Go to **Settings > System > Developer Options** and toggle OFF * Restart your device after disabling developer mode * Ensure the tap-to-pay kernel app is running and up to date * Check that NFC is enabled in device settings **Build errors** **Solution**: * Verify JDK 21 is configured * The public demo declares `minSdk = 31`; use a supported Android 13+ device for the reader flow. Your own app can set `minSdk = 33` to enforce that device requirement. * Clean and rebuild: `./gradlew clean build` * Invalidate caches: **File > Invalidate Caches / Restart** **Network errors** **Solution**: * Check device has internet connection * Verify API key and credentials are correct * Ensure you're using the correct build flavor for your environment (uat/prod) ### Debugging Tips **Enable verbose logging:** SDK log verbosity is set **once**, at initialization — there is no runtime setter. Pass a `KoardLogLevel` in `DemoApplication.onCreate()`: ```kotlin import com.koardlabs.merchant.sdk.domain.KoardLogLevel KoardMerchantSdk.initialize( application = this@DemoApplication, apiKey = BuildConfig.API_KEY, environment = environment, timeoutSeconds = 30L, logLevel = KoardLogLevel.VERBOSE ) ``` Levels are `VERBOSE`, `DEBUG`, `INFO`, `WARN`, `ERROR`, and `NONE`. The default is `DEBUG` in debug builds and `NONE` in release, and the level applies in release builds too if you opt in. Secrets — API key and token headers, device certificates, and enrollment key material — are never logged at any level. **Check Logcat:** The SDK logs under the fixed tag `KoardSDK`: ```bash adb logcat -s KoardSDK ``` **Verify SDK version:** Check which SDK version the demo is using: ```bash ./gradlew dependencies | grep koard ``` ## Testing SDK lifecycle and metadata The public 1.0.7 demo provides **Settings → Reset SDK and sign out**. It calls `deinit()`, initializes a fresh instance, reattaches the foreground Activity for NFC, and returns to login. Settings also shows `isInitialized()`. Ordinary Logout preserves enrollment. See [SDK Lifecycle and Deinitialization](/docs/setting-up-the-android-sdk/sdk-lifecycle#verify-the-flow-in-the-demo). Sales, preauthorizations, API refunds, and tap refunds send example metadata from `TransactionMetadata.kt`. SDK logging is explicitly `KoardLogLevel.NONE`; release builds are minified and non-debuggable. To test custom payment context, pass metadata when calling sale, preauth, or refund and verify the stored event through the API. [Transaction Metadata](/docs/setting-up-the-android-sdk/transaction-metadata) includes examples and retrieval steps; the demo receipt is not a metadata viewer. ## Security Notes * Never commit actual credentials to version control * Use environment variables or secure credential management for production * The `build.gradle.kts` credentials are for demo purposes only * Consider using Android Keystore for sensitive data in production apps * Keep the demo app on devices used exclusively for testing ## Updating the SDK The demo currently uses SDK 1.0.7. For a later published release, update the dependency version in `build.gradle.kts`, then clean-rebuild: ```kotlin implementation("com.koard:koard-android-sdk:1.0.7") ``` Keep `google()` and `mavenCentral()` in `settings.gradle.kts` as configured during setup. No local mirror or version filter needs updating. 1.0.7 adds optional metadata parameters to payment methods, changing their JVM signatures. Clean-rebuild the app and any native wrapper. Kotlin source callers can omit metadata; Java callers must pass the added argument (`null` if unused). Amounts remain `Int`. Handle the new `KoardReaderStatus` values or add an `else` branch to exhaustive `when` expressions. ## Next Steps After running the demo app successfully: * [Install SDK in Your App](/docs/setting-up-the-android-sdk/installing-the-sdk) - Integrate SDK into your own application, including login, location selection, and enrollment * [Running Payments](/docs/setting-up-the-android-sdk/running-payments) - Drive tap-to-pay sessions and post-reader operations * [SDK Response Codes](/docs/setting-up-the-android-sdk/sdk-response-codes) - Understand transaction outcomes, error codes, and error handling * [Payment Lifecycle](/docs/payments/payment-lifecycle) - Understand payment flows * [Supported Devices & NFC Tap Location](/docs/setting-up-the-android-sdk/supported-devices) - Device compatibility and where to tap * [Troubleshooting](/docs/setting-up-the-android-sdk/troubleshooting) - Fix enrollment failures and taps that cancel instantly ## Support For technical support or questions: * Email: * Documentation: * GitHub Issues: # Payments Learn how to orchestrate Koard payment flows across sale, preauthorization, capture, adjustment, reversal, and refund operations. ## Available Guides * [Idempotency](/docs/payments/idempotency): Persisting `event_id` and recovering uncertain payment outcomes * [Sale](/docs/payments/methods/sale) – One-step auth + capture transactions * [Preauth](/docs/payments/methods/preauth) – Hold funds before finalizing totals * [Capture](/docs/payments/methods/capture) – Settle preauthorized amounts * [Incremental Auth](/docs/payments/methods/incremental-auth) – Increase an existing authorization * [Tip Adjust](/docs/payments/methods/tip-adjust) – Update gratuity before settlement * [Reverse](/docs/payments/methods/reverse) – Release funds from a [preauth](/docs/appendix/glossary#preauth "Authorization hold without capture; must later capture or reverse.") * [Refund](/docs/payments/methods/refund) – Return captured funds * [Payment Lifecycle](/docs/payments/payment-lifecycle) – End-to-end transaction flow * [Surcharging](/docs/payments/surcharging) - Implementing [Surcharging](/docs/appendix/glossary#surcharge "Fee on eligible credit-card transactions to offset processing costs (not debit).") **Looking for SDK usage?** Start with the [iOS Running Payments guide](/docs/setting-up-the-ios-sdk/running-payments) and pair it with these payment references. # Idempotency and Outcome Recovery Persist a client-generated `event_id` before submitting each payment operation that exposes one. Retain it with your order and the returned `transaction_id`. A new event ID represents a new operation; it is not a way to retry an uncertain payment. ## Identifiers | Identifier | Purpose | |---|---| | `event_id` | Identifies one operation, such as a sale, capture, or refund | | `transaction_id` | Groups the original payment and its follow-up events | Generate a UUID4 for `event_id`. Save it durably before the request leaves the device or backend. Keep the outcome and identifiers for reconciliation after app restarts. If you omit it, Koard can generate an ID, but a lost response then leaves your client without the lookup identifier. SDK arguments vary by released platform version. For example, iOS capture accepts `eventId`, while Android 1.0.7 capture and tip adjustment do not expose that argument. Follow the platform-specific API instead of inventing an argument. ## Duplicate Responses A duplicate event ID is rejected with HTTP 400 and the stable error code `duplicate_event_id`. Look up that event's outcome; do not treat the duplicate error as a decline or generate a new ID to submit again. Branch on `error`, not human-readable `details` text. ## Look Up an Event Your backend can query: ```bash curl 'https://api.uat.koard.com/v1/transactions/event/YOUR_PERSISTED_EVENT_ID' \ -H 'X-Koard-apikey: YOUR_API_KEY' ``` Requires `payments:read` and access to the account owning the event. Keep backend API keys out of shipped apps; expose an authenticated recovery endpoint through your backend if needed. | Response | Action | |---|---| | `200` with a completed operation | Reconcile the transaction status, amounts, and processor response with your order | | `200` with `pending` | Keep the operation unresolved and poll with bounded backoff | | `200` with `surcharge_pending` | Complete the supported cardholder confirmation flow; polling alone does not confirm the surcharge | | `404` | No visible committed event was found. The request may still be in flight, or your key may be querying the wrong account or environment | | Authentication or permission failure | Correct access; do not infer a payment outcome from this response | | Lookup timeout or server error | Keep the outcome unresolved and retry the lookup with backoff | **A 404 does not prove the original POST never reached the processor.** Do not immediately resubmit, switch event IDs, or replay one-time EMV reader data or cryptograms based solely on a missing lookup result. ## Recovery Sequence 1. Save the operation's event ID, order, intended amount, merchant account, location, and environment before submitting. 2. Submit once through the supported SDK or REST endpoint. 3. Inspect a successful response's transaction status and amounts. Pending states, partial approvals, and recorded errors need their appropriate follow-up; HTTP 2xx alone is not enough. 4. For a duplicate event, timeout, lost response, or server error, stop automatic payment retries and look up the saved event ID. 5. Poll with bounded backoff, for example 1, 2, 5, 10, and 30 seconds. Reconcile against your signed webhook deliveries and processor reports when available. 6. If the outcome remains unresolved, retain the identifiers and flag the order for investigation. Start a new reader session or operation only after resolving the previous outcome through the supported recovery process. Duplicate detection is a safeguard, not a promise that concurrent or in-flight retries are safe. Avoid parallel submissions of the same operation. ## Webhook Reconciliation Verify webhook signatures and deduplicate by delivery ID. Transaction bodies include `event_id` and `transaction_id`; store them with the event outcome. A webhook mirror in your backend can support recovery when the app cannot reach Koard, but delayed or missing webhook delivery is not evidence of failure. See [Payment Lifecycle](payment-lifecycle.md), [Available Events](/docs/webhooks/available-events), and [Webhook Setup](/docs/webhooks/setting-up-webhooks). # Tax and Tip Handling Koard resolves **tax** settings hierarchically, and handles **tips** either at the time of sale or as a later adjustment. > [Surcharge](/docs/appendix/glossary#surcharge "Fee on eligible credit-card transactions to offset processing costs (not debit).") settings (`surcharge_rate`, `surcharge_basis`, `surcharge_confirmation_required`) use the same location, terminal, then account resolution order described below. See [Surcharging](/docs/payments/surcharging). ## Hierarchical tax defaults `tax_rate` and `tax_basis` can be configured at **three** levels — the **account**, the **location**, and the **terminal**: | Field | Meaning | | ----------- | ------------------------------ | | `tax_rate` | Tax percentage. | | `tax_basis` | What the tax is calculated on. | **Resolution order:** Koard uses the first non-null setting from the **location**, then the **terminal**, then the merchant **account**. A `null` inherits from the next level. An explicit zero rate stops inheritance and disables the default tax. Set the broad default once on the account. Only set the field on a location or terminal when it needs to differ from the level above it. `tax_rate` and `tax_basis` here are **configuration defaults** (resolved in location → terminal → account order, with an explicit zero stopping inheritance). They are separate from the **transaction-breakdown** fields returned on a completed transaction: `taxAmount` (the computed tax, in **cents**) and `taxRate` (the rate actually applied to that transaction; see the transaction breakdown schema for its exact representation). Don't conflate the config input (`tax_rate`, a percentage) with the computed breakdown output (`taxRate` / `taxAmount`). ## Sending tax you calculated For a basket with multiple product tax rates, send the combined tax in `breakdown.taxAmount` and omit `taxRate` or set it to `null`. A supplied `taxAmount`, including zero, is authoritative: Koard does not recalculate it from a rate or add the configured default tax. Setting account `tax_rate: 0` is compatible with this flow, but is not required to override a default when you supply `taxAmount`. For example, this breakdown requests a $100.00 subtotal and exactly $7.25 tax: ```json { "subtotal": 10000, "taxAmount": 725, "taxRate": null, "tipAmount": 0, "tipType": "fixed" } ``` Pass `amount: 10725` before any surcharge calculated by Koard. The resulting sale reports `subtotal: 10000` and `tax_amount: 725`; an eligible surcharge changes `total_amount`, not those two components. A null request rate is not a promise that the response's `tax_rate` stays null. The released iOS 1.0.21 and Android 1.0.7 `PaymentBreakdown` models accept a nullable `taxRate`. Use `taxRate: nil` in Swift or `taxRate = null` in Kotlin with the explicit `taxAmount`. These are request fields in camelCase; API and webhook transaction fields use `tax_amount` and `tax_rate`. ## Tip handling Tips are captured in one of two modes, tracked on the transaction as `tip_capture_mode`: | Mode | When | Notes | | ------------------------------- | ----------------------------------------------------------------------------- | -------------------------------------------------------------- | | **Tip at sale** (`tip_at_sale`) | The tip is included on the original sale. | Any non-tip-adjust transaction that carries a tip. | | **Tip adjust** (`tip_adjust`) | The tip is added/changed **after** the sale (e.g. restaurant tip-on-receipt). | A dedicated `tip_adjust` transaction referencing the original. | Each carries a `tip_amount` and `tip_type` (e.g. `percentage` or a fixed amount). **Tip adjust does not apply surcharge.** A tip adjustment only changes the tip on an existing transaction — it does not recompute or add a surcharge. Surcharge is applied on the **sale**, independently of tips (see [Surcharging](/docs/payments/surcharging)). # Surcharging Koard supports configured surcharges on eligible credit-card transactions. Eligibility, supported amounts, disclosures, and cardholder confirmation depend on the processor and merchant configuration. Do not apply surcharges to debit cards. Confirm your surcharge rules with your processor before enabling them. ## Configuration Defaults Configure the public surcharge defaults on the merchant account, terminal, or location where your account has update access: | Field | Meaning | | --------------------------------- | ------------------------------------------------------------------------- | | `surcharge_rate` | Whole percent units, for example `3.5` for 3.5% | | `surcharge_basis` | `total`, `subtotal`, `subtotal_plus_tax`, or `subtotal_plus_tip` | | `surcharge_confirmation_required` | Whether the cardholder must confirm a surcharge before the sale continues | The first non-null setting from location, then terminal, then account applies. A null value inherits; zero rate disables the default surcharge. The default basis is `total`, including subtotal, tax, and tip. Do not assume every merchant uses that basis. See [Tax and Tip Handling](/docs/payments/tax-and-tip-handling). ### Calculation and eligibility For a configured default, `surcharge_basis: subtotal` excludes both `taxAmount` and `tipAmount`. The other bases include exactly the components named above. Koard calculates in minor currency units and rounds to the nearest integer using **half-even** rounding: an exact half-cent goes to the even cent. For example, 3% of 150 cents is 4.5 cents and rounds to 4 cents. This describes the server-calculated default; a supplied fixed `surcharge.amount` is already an integer and is not recalculated. The automatic surcharge gate excludes debit cards and cards classified as prepaid or unknown by Koard's BIN data. Eligibility depends on the available card classification, including upstream lookup results; do not use a credit-network brand alone as proof of eligibility. On the Fiserv Nashville Global route, an applied surcharge is included once in the authorized total and also sent in the processor's dedicated surcharge amount field. That field describes a component of the total, not an extra charge on top. ## Request Overrides A breakdown's surcharge object accepts **one of**: ```json {"amount": 451} ``` ```json {"percentage": 3.5} ``` ```json {"bypass": true} ``` `amount` is an integer in minor currency units. `percentage` uses whole percent units, so `3.5` means 3.5%, not `0.035`. Never send both amount and percentage. When `bypass` is true, omit both. For an explicitly calculated fee, include it in the final request total and use the matching fixed surcharge amount. For example, USD subtotal 10000 + tax 875 + tip 2000 gives a base of 12875 cents. At a 3.5% total basis, round the fee to 451 cents and send a total of 13326 cents. **iOS:** ```swift let breakdown = PaymentBreakdown( subtotal: 10000, taxRate: 8.75, taxAmount: 875, tipAmount: 2000, tipType: .fixed, surcharge: PaymentBreakdown.Surcharge(amount: 451) ) let response = try await KoardMerchantSDK.shared.sale( amount: 13326, breakdown: breakdown, currency: CurrencyCode(currencyCode: "USD", displayName: "US Dollar"), eventId: eventId ) ``` On Android, supply `Surcharge(amount = 451)` in the `PaymentBreakdown` passed to the released SDK's sale flow. See the full [Android payment flow](/docs/setting-up-the-android-sdk/running-payments) for Activity, coroutine, and reader-event handling. ## Flow 1: Sale with Cardholder Confirmation When a sale returns `surcharge_pending` (iOS SDK `surchargePending`), show the surcharge disclosure and final amount to the cardholder and complete the supported SDK confirmation flow. Do not mark the sale captured while confirmation is pending. Inspect the confirmation's resulting status and amounts. Do not independently resend the original sale. The confirmation arguments and reader states differ between platforms. Follow the [iOS](/docs/setting-up-the-ios-sdk/running-payments) or [Android](/docs/setting-up-the-android-sdk/running-payments) flow. A preauth does not use this sale confirmation workflow. ### Pending amounts, rejection and expiry When the effective `surcharge_confirmation_required` is true and the calculated fee is positive, an eligible sale returns `surcharge_pending` with `surcharge_amount` and the final `total_amount` already calculated. A zero fee or excluded card does not require surcharge confirmation. For Fiserv Nashville Global, this pending step occurs **before processor authorization**. Accepting through `confirm(..., true)` submits the payment. Declining through `confirm(..., false)` cancels the pending sale with `status: canceled` and `status_reason: surcharge_declined_by_payer`; there is no authorization from this pending step to reverse. The saved confirmation context expires after **120 seconds**. An attempt to accept after expiry returns `status: canceled` with `status_reason: surcharge_expired`. `surcharge_expired` is a reason, not a transaction status. Background cleanup can also mark stale pending sales canceled; the visible state can update later than the 120-second confirmation deadline, so do not depend on a status change at exactly two minutes. On Android, `cancelTransaction()` only cancels the reader session. It does not submit a surcharge decision or reverse a backend transaction. Once you have a `surcharge_pending` transaction, reject it with `sdk.confirm(transactionId = transactionId, confirm = false, eventId = decisionEventId)` and inspect the result. See [Confirming a surcharge](/docs/setting-up-the-android-sdk/running-payments#confirming-a-surcharge). Declining or expiring a pending surcharge emits `transaction.cancel` with the cancellation reason. Use the confirmation response or [cancellation webhook](/docs/webhooks/available-events#surcharge-cancellation) to update checkout, and [outcome recovery](/docs/payments/idempotency) if the response is lost. ## Flow 2: Custom Surcharge via Preauth For a processor-approved custom surcharge flow: 1. Create a preauth with `surcharge: {"bypass": true}` in the breakdown. 2. Inspect the returned `card_type` and other supported eligibility information. The transaction response has **no `bin` property**. If your integration requires an external BIN lookup, use an approved data source; do not infer a BIN from masked card data. 3. For an eligible credit card, calculate the permitted fee using the configured basis and rounding rules. 4. Where supported, increase the hold by the fee through incremental authorization. 5. Capture the final total with a complete breakdown containing the fixed surcharge amount. An incremental authorization request can omit breakdown entirely: ```http POST /v3/payments/{transaction_id}/auth X-Koard-apikey: YOUR_API_KEY Content-Type: application/json ``` ```json {"amount": 451, "event_id": "YOUR_INCREMENT_EVENT_ID"} ``` The legacy incremental-auth breakdown does not accept a nested surcharge object. Android 1.0.7 `incrementalAuth` does not expose a breakdown argument either. Include the final supported breakdown at capture: ```http POST /v4/payments/{transaction_id}/capture X-Koard-apikey: YOUR_API_KEY Content-Type: application/json ``` ```json { "amount": 13326, "event_id": "YOUR_CAPTURE_EVENT_ID", "breakdown": { "subtotal": 10000, "taxAmount": 875, "taxRate": 8.75, "tipAmount": 2000, "tipType": "fixed", "surcharge": {"amount": 451} } } ``` Keep the original preauth, increment, and capture event IDs distinct and persist each before submission. Verify the increment succeeded before capturing an increased amount. ## Remove a Fee Before Capture If a fee is not permitted, remove it from the final breakdown and capture the lower supported amount. Use a surcharge bypass without amount or percentage. Remaining holds are released according to processor and issuer rules; do not promise immediate availability to the cardholder. ## Follow-Up Operations Tip adjustment does not recalculate the original surcharge. Refund and reversal treatment depends on processor rules; do not assume every processor automatically prorates a surcharge on a partial refund. Reconcile the returned amounts and processor report. See [Capture](/docs/payments/methods/capture), [Incremental Auth](/docs/payments/methods/incremental-auth), [Tip Adjust](/docs/payments/methods/tip-adjust), [Refund](/docs/payments/methods/refund), and [Idempotency](/docs/payments/idempotency). # Sale A sale authorizes and captures a payment in a single step—use it when the final amount is known at checkout. ## Prerequisites * Authenticated merchant with `login()` * Active location selected in app settings via `setActiveLocationID()` (iOS) or `setActiveLocation()` (Android) * Account linking and reader preparation completed on iOS, or location selection, enrollment, and reader preparation completed on Android Persist `eventId` before submission. Create Android payment flows on `Dispatchers.IO`, apply `flowOn(Dispatchers.IO)`, and collect from a Main/UI coroutine. Handle the full reader event flow described in the [Android guide](/docs/setting-up-the-android-sdk/running-payments). Use the `handleTransactionEvent` implementation in [Android Running Payments](/docs/setting-up-the-android-sdk/running-payments). It handles progress, failure, pending or partial results, surcharge confirmation, and final approval or decline. The UI callbacks run on the main dispatcher. ## Basic Sale **iOS:** ```swift let breakdown = PaymentBreakdown( subtotal: 10000, // $100.00 in cents taxRate: 8.75, // 8.75% as a percent value taxAmount: 875, // $8.75 in cents tipAmount: 2000, // $20.00 tip tipType: .fixed ) let currency = CurrencyCode(currencyCode: "USD", displayName: "US Dollar") let response = try await KoardMerchantSDK.shared.sale( amount: 12875, // subtotal + tax + tip breakdown: breakdown, currency: currency, eventId: eventId ) ``` **Android:** ```kotlin val breakdown = PaymentBreakdown( subtotal = 10000, taxRate = 8.75, taxAmount = 875, tipAmount = 2000, tipType = "fixed" ) val flow = withContext(Dispatchers.IO) { sdk.sale( activity = activity, amount = 12875, breakdown = breakdown, eventId = eventId ) }.flowOn(Dispatchers.IO) flow.collect { event -> handleTransactionEvent(event) } ``` ## Sale with Surcharge For a configured `total` surcharge basis, the [surcharge](/docs/appendix/glossary#surcharge "Fee on eligible credit-card transactions to offset processing costs (not debit).") percentage is applied to the **full transaction amount** (subtotal + tax + tip). Other configured bases differ. This example uses a fixed surcharge amount: ```plaintext surcharge = (subtotal + taxAmount + tipAmount) × (surchargePercentage / 100) = (10000 + 875 + 2000) × (3.5 / 100) = 451 cents ($4.51) ``` **iOS:** ```swift let breakdown = PaymentBreakdown( subtotal: 10000, taxRate: 8.75, taxAmount: 875, tipAmount: 2000, tipType: .fixed, surcharge: PaymentBreakdown.Surcharge( amount: 451 // surcharge on full amount ) ) let response = try await KoardMerchantSDK.shared.sale( amount: 13326, // 12875 + 451 surcharge breakdown: breakdown, currency: currency, eventId: eventId ) ``` **Android:** ```kotlin val breakdown = PaymentBreakdown( subtotal = 10000, taxRate = 8.75, taxAmount = 875, tipAmount = 2000, tipType = "fixed", surcharge = Surcharge( amount = 451 ) ) val flow = withContext(Dispatchers.IO) { sdk.sale( activity = activity, amount = 13326, breakdown = breakdown, eventId = eventId ) }.flowOn(Dispatchers.IO) flow.collect { event -> handleTransactionEvent(event) } ``` ## Surcharge Confirmation When the configured sale flow requires cardholder surcharge confirmation, the response is `surchargePending` on iOS or `surcharge_pending` in REST. Present the disclosure and final amount, then confirm or decline through the supported SDK flow. Do not assume every surcharged sale requires confirmation. **iOS:** ```swift if let transaction = response.transaction, transaction.status == .surchargePending { let disclosure = response.transaction?.surchargeDisclosure ?? "" let approved = await showSurchargeDisclosure(disclosure) let confirmed = try await KoardMerchantSDK.shared.confirm( transaction: transaction.transactionId, confirm: approved, amount: nil, breakdown: nil, eventId: confirmationEventId ) renderChargeResult(confirmed) // Inspect the returned KoardTransaction } ``` **Android:** See the current Android reader and confirmation flow in the [Android Running Payments guide](/docs/setting-up-the-android-sdk/running-payments). ## Bypassing Automatic Surcharge Set `bypass: true` to skip the processor's automatic surcharge. Useful for [custom BIN-based surcharging](/docs/payments/surcharging#bin-based-custom-surcharge): **iOS:** ```swift let breakdown = PaymentBreakdown( subtotal: 10000, taxRate: 8.75, taxAmount: 875, tipType: .fixed, surcharge: PaymentBreakdown.Surcharge(bypass: true) ) ``` **Android:** ```kotlin val breakdown = PaymentBreakdown( subtotal = 10000, taxRate = 8.75, taxAmount = 875, tipType = "fixed", surcharge = Surcharge(bypass = true) ) ``` ## Parameters | Parameter | Type | Required | Description | | ----------- | ------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | `amount` | `Int` | Yes | Total amount in minor units (cents) | | `breakdown` | `PaymentBreakdown?` | No | Itemized breakdown (see below) | | `currency` | `CurrencyCode` | Yes (iOS) | Currency for the transaction | | `eventId` | `String?` | No | [Idempotency](/docs/appendix/glossary#idempotency "Safe retry pattern using event_id so network failures do not double-charge.") key (UUID recommended) | | `activity` | `Activity` | Yes (Android) | Android activity for NFC access | ### PaymentBreakdown | Field | Type | Description | | ----------- | ------------ | ---------------------------------------------------------------------- | | `subtotal` | `Int` | Base amount in minor units | | `taxRate` | `Double?` | Tax rate as a percent value (`8.75` = 8.75%) | | `taxAmount` | `Int` | Calculated tax in minor units | | `tipAmount` | `Int?` | Tip in minor units | | `tipRate` | `Double?` | Tip rate as decimal (alternative to fixed tip) | | `tipType` | `TipType` | `.fixed` / `.percentage` (iOS) or `"fixed"` / `"percentage"` (Android) | | `surcharge` | `Surcharge?` | Nested surcharge object | ### Surcharge | Field | Type | Default | Description | | ------------ | --------- | ------- | --------------------------------------------------------------------------------- | | `amount` | `Int?` | `nil` | Fixed surcharge in minor units | | `percentage` | `Double?` | `nil` | Whole percent units: `3.5` means 3.5%. Applied to the configured surcharge basis. | | `bypass` | `Bool` | `false` | Skip automatic surcharge calculation | ## See Also * [Preauth](/docs/payments/methods/preauth) — Hold funds now, capture later * [Surcharging](/docs/payments/surcharging) — Automatic and custom surcharge workflows * [Payment Lifecycle](/docs/payments/payment-lifecycle) — End-to-end payment flow # Preauth A preauthorization places a hold on the cardholder's funds without capturing. Use it when the final amount may change (e.g., tips, adjustments, custom [surcharging](/docs/appendix/glossary#surcharge "Fee on eligible credit-card transactions to offset processing costs (not debit).")). ## Prerequisites * Authenticated merchant with `login()` * Active location selected in app settings via `setActiveLocationID()` (iOS) or `setActiveLocation()` (Android) * Account linking and reader preparation completed on iOS, or location selection, enrollment, and reader preparation completed on Android Persist `eventId` before submission. Create Android payment flows on `Dispatchers.IO`, apply `flowOn(Dispatchers.IO)`, and collect from a Main/UI coroutine. Handle the full reader event flow described in the [Android guide](/docs/setting-up-the-android-sdk/running-payments). Use the `handleTransactionEvent` implementation in [Android Running Payments](/docs/setting-up-the-android-sdk/running-payments). It handles progress, failure, pending or partial results, surcharge confirmation, and final approval or decline. The UI callbacks run on the main dispatcher. ## Basic Preauth **iOS:** ```swift let breakdown = PaymentBreakdown( subtotal: 10000, taxRate: 8.75, taxAmount: 875, tipType: .fixed ) let currency = CurrencyCode(currencyCode: "USD", displayName: "US Dollar") let response = try await KoardMerchantSDK.shared.preauth( amount: 10875, breakdown: breakdown, currency: currency, eventId: eventId ) try handleTransactionResponse(response) // Handler in iOS Running Payments ``` **Android:** ```kotlin val breakdown = PaymentBreakdown( subtotal = 10000, taxRate = 8.75, taxAmount = 875, tipType = "fixed" ) val flow = withContext(Dispatchers.IO) { sdk.preauth( activity = activity, amount = 10875, breakdown = breakdown, eventId = eventId ) }.flowOn(Dispatchers.IO) flow.collect { event -> handleTransactionEvent(event) } ``` ## Preauth with Surcharge Bypass To calculate surcharges yourself (e.g., [BIN-based](/docs/appendix/glossary#bin "Bank Identification Number (card prefix) used for surcharging and some VAR sheets.") logic), bypass the processor's automatic surcharge: **iOS:** ```swift let breakdown = PaymentBreakdown( subtotal: 10000, taxRate: 8.75, taxAmount: 875, tipAmount: 2000, tipType: .fixed, surcharge: PaymentBreakdown.Surcharge(bypass: true) ) let response = try await KoardMerchantSDK.shared.preauth( amount: 12875, // subtotal + tax + tip (no surcharge yet) breakdown: breakdown, currency: currency, eventId: eventId ) ``` **Android:** ```kotlin val breakdown = PaymentBreakdown( subtotal = 10000, taxRate = 8.75, taxAmount = 875, tipAmount = 2000, tipType = "fixed", surcharge = Surcharge(bypass = true) ) val flow = withContext(Dispatchers.IO) { sdk.preauth( activity = activity, amount = 12875, breakdown = breakdown, eventId = eventId ) }.flowOn(Dispatchers.IO) flow.collect { event -> handleTransactionEvent(event) } ``` After the preauth is authorized, use the returned card type and your configured eligibility rules to calculate any permitted custom surcharge, then apply it via [incremental auth](/docs/payments/methods/incremental-auth). The public response does not expose a BIN. If your approved workflow uses BIN classification, obtain it through that integration. Never apply a surcharge to debit cards. See the [custom surcharge workflow](/docs/payments/surcharging#bin-based-custom-surcharge) for the full flow. ## Parameters | Parameter | Type | Required | Description | | ----------- | ------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | `amount` | `Int` | Yes | Hold amount in minor units (cents) | | `breakdown` | `PaymentBreakdown?` | No | Itemized breakdown | | `currency` | `CurrencyCode` | Yes (iOS) | Currency for the transaction | | `eventId` | `String?` | No | [Idempotency](/docs/appendix/glossary#idempotency "Safe retry pattern using event_id so network failures do not double-charge.") key (UUID recommended) | | `activity` | `Activity` | Yes (Android) | Android activity for NFC access | ## After Preauth A preauth hold must be followed by one of: | Action | Description | | ----------------------------------------------------------- | --------------------------------------- | | [Capture](/docs/payments/methods/capture) | Finalize at the same or lower amount | | [Incremental Auth](/docs/payments/methods/incremental-auth) | Increase the hold (e.g., add surcharge) | | [Tip Adjust](/docs/payments/methods/tip-adjust) | Update the tip before capture | | [Reverse](/docs/payments/methods/reverse) | Void the hold entirely | ## See Also * [Sale](/docs/payments/methods/sale) — One-step authorize + capture * [Capture](/docs/payments/methods/capture) — Finalize a preauth * [Surcharging](/docs/payments/surcharging) — Bypass and custom surcharge workflows # Capture Capture finalizes a previously authorized ([preauth](/docs/payments/methods/preauth)) transaction. You can capture at the original amount, a lower amount (partial capture), or with an updated breakdown that includes tip or [surcharge](/docs/appendix/glossary#surcharge "Fee on eligible credit-card transactions to offset processing costs (not debit)."). Run Android reference operations from `withContext(Dispatchers.IO)`. The examples unwrap `Result` with `getOrThrow()`; handle thrown errors at the UI boundary. Inspect the returned transaction status and operation amounts before displaying success. See the [Android operation helpers](/docs/setting-up-the-android-sdk/running-payments#post-reader-operations). ## Prerequisites * An existing [preauth](/docs/appendix/glossary#preauth "Authorization hold without capture; must later capture or reverse.") transaction ID * Transaction must be in an authorized (uncaptured) state ## Full Capture **iOS:** ```swift let response = try await KoardMerchantSDK.shared.capture( transactionId: transactionId, amount: 10875 ) ``` **Android:** ```kotlin val transaction = sdk.capture( transactionId = transactionId, amount = 10875 ).getOrThrow() ``` ## Capture with Updated Breakdown Include the final breakdown when the tip or surcharge changed after the preauth. If the final amount exceeds the current authorization, obtain any required [incremental authorization](/docs/payments/methods/incremental-auth) first. Each surcharge specifies an amount or a percentage, never both: **iOS:** ```swift let finalBreakdown = PaymentBreakdown( subtotal: 10000, taxRate: 8.75, taxAmount: 875, tipAmount: 3000, // customer added a $30 tip tipType: .fixed, surcharge: PaymentBreakdown.Surcharge( amount: 486 // 3.5% of (10000 + 875 + 3000) = 486 ) ) let response = try await KoardMerchantSDK.shared.capture( transactionId: transactionId, amount: 14361, // 10000 + 875 + 3000 + 486 breakdown: finalBreakdown ) ``` **Android:** ```kotlin val finalBreakdown = PaymentBreakdown( subtotal = 10000, taxRate = 8.75, taxAmount = 875, tipAmount = 3000, tipType = "fixed", surcharge = Surcharge( amount = 486 ) ) sdk.capture( transactionId = transactionId, amount = 14361, breakdown = finalBreakdown ).getOrThrow() ``` ## Partial Capture Capture at a lower amount than the original hold: **iOS:** ```swift let response = try await KoardMerchantSDK.shared.capture( transactionId: transactionId, amount: 8000 // capture $80 of a $108.75 hold ) ``` **Android:** ```kotlin val transaction = sdk.capture( transactionId = transactionId, amount = 8000 ).getOrThrow() ``` > Any remaining authorization hold is released according to the processor and issuer rules; do not promise an immediate release. ## Parameters | Parameter | Type | Required | Description | | -------------------------------------------- | ------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------ | | `transactionId` | `String` | Yes | The preauth transaction to capture | | `amount` | `Int?` | No | Capture amount in minor units. Defaults to the original preauth amount. | | `breakdown` | `PaymentBreakdown?` | No | Updated breakdown with final tip/surcharge | | `eventId` (iOS only in the current releases) | `String?` | No | [Idempotency](/docs/appendix/glossary#idempotency "Safe retry pattern using event_id so network failures do not double-charge.") key | ## See Also * [Preauth](/docs/payments/methods/preauth) — Place a hold before capturing * [Incremental Auth](/docs/payments/methods/incremental-auth) — Increase the hold before capture * [Tip Adjust](/docs/payments/methods/tip-adjust) — Update tip before capture * [Surcharging](/docs/payments/surcharging) — Include surcharge in capture breakdown --- title: Incremental Auth --- # Incremental Auth Incremental authorization increases the hold on an existing [preauth](preauth.md), where the processor and merchant configuration support it. `amount` is the additional amount to authorize in minor currency units, not the new total. Run Android reference operations from `withContext(Dispatchers.IO)`. The examples unwrap `Result` with `getOrThrow()`; handle thrown errors at the UI boundary. Inspect the returned transaction status and operation amounts before displaying success. See the [Android operation helpers](../../android-sdk/details/running-payments.md#post-reader-operations). ## Increase a Hold **iOS 1.0.21:** ```swift let response = try await KoardMerchantSDK.shared.auth( transactionId: transactionId, amount: 2000, // Increase the hold by $20.00 eventId: eventId // Persist before submitting this operation ) ``` **Android 1.0.7:** ```kotlin val transaction = sdk.incrementalAuth( transactionId = transactionId, amount = 2000, eventId = eventId ).getOrThrow() ``` Android 1.0.7 does not accept a `breakdown` argument on `incrementalAuth`. Inspect the returned transaction before proceeding to capture. ## REST ```http POST /v3/payments/{transaction_id}/auth X-Koard-apikey: YOUR_API_KEY Content-Type: application/json ``` ```json {"amount": 2000, "event_id": "YOUR_PERSISTED_EVENT_ID"} ``` Requires `payments:incremental-auth`. Omit `breakdown` when increasing the hold alone. This endpoint's optional legacy breakdown does not accept a nested surcharge object. Supply the final supported breakdown when [capturing](capture.md). ## Custom Surcharges For an eligible credit-card preauth, an approved custom surcharge flow can increase the hold by the surcharge amount before capture. Confirm eligibility from the returned card information and your processor's rules. The transaction response does not expose a `bin` property; do not assume one exists or use masked card data as a substitute for a verified BIN lookup. See [Surcharging](surcharging.md#bin-based-custom-surcharge). ## See Also - [Preauth](preauth.md) - [Capture](capture.md) - [Idempotency](idempotency.md) --- title: Tip Adjust --- # Tip Adjust Tip adjust changes gratuity on an existing transaction before settlement, where the processor and merchant configuration support it. Use the transaction ID returned by the original payment. Availability and timing depend on the processor's settlement cutoff. Run Android reference operations from `withContext(Dispatchers.IO)`. The examples unwrap `Result` with `getOrThrow()`; handle thrown errors at the UI boundary. Inspect the returned transaction status and operation amounts before displaying success. See the [Android operation helpers](../../android-sdk/details/running-payments.md#post-reader-operations). ## Fixed Tip **iOS 1.0.21:** ```swift let response = try await KoardMerchantSDK.shared.tipAdjust( transactionId: transactionId, amount: 3000, // $30.00 tip, not the new transaction total tipType: .fixed, eventId: eventId // Persist before submitting this operation ) ``` **Android 1.0.7:** ```kotlin import com.koardlabs.merchant.sdk.domain.AmountType val transaction = sdk.adjust( transactionId = transactionId, type = AmountType.FIXED, amount = 3000 ).getOrThrow() ``` Inspect the returned transaction and processor response before marking the adjustment successful. The Android method is `adjust`; the iOS method is `tipAdjust`. Android 1.0.7 does not expose an `eventId` argument for `adjust`. ## REST ```http POST /v1/payments/{transaction_id}/adjust X-Koard-apikey: YOUR_API_KEY Content-Type: application/json ``` For a fixed tip: ```json {"type": "fixed", "amount": 3000, "event_id": "YOUR_PERSISTED_EVENT_ID"} ``` For a percentage tip: ```json {"type": "percentage", "percentage": 18, "event_id": "YOUR_PERSISTED_EVENT_ID"} ``` REST `percentage` is an integer from 1 through 100: `18` means 18%, not `0.18`. A fixed `amount` must be a positive integer in minor currency units. Requires `payments:tipadjust`. ## Surcharge Behavior Tip adjustment does not recalculate the existing surcharge. If the final breakdown changes, use the supported [capture](capture.md) flow and verify the processor's authorization limits before increasing the final amount. ## See Also - [Preauth](preauth.md) - [Capture](capture.md) - [Idempotency](idempotency.md) # Reverse (Void) A reverse voids a transaction **before** settlement, requesting release of the authorization hold according to the processor and issuer rules. Use it to cancel a sale or [preauth](/docs/appendix/glossary#preauth "Authorization hold without capture; must later capture or reverse.") that hasn't settled yet. > For returning funds **after** settlement, see [Refund](/docs/payments/methods/refund). Persist a separate `eventId` with your order before each operation below, and pass that saved value to the SDK. On a timeout or lost response, reconcile that ID instead of generating a new one. See [Idempotency and Outcome Recovery](/docs/payments/idempotency). Run Android reference operations from `withContext(Dispatchers.IO)`. The examples unwrap `Result` with `getOrThrow()`; handle thrown errors at the UI boundary. Inspect the returned transaction status and operation amounts before displaying success. See the [Android operation helpers](/docs/setting-up-the-android-sdk/running-payments#post-reader-operations). ## Full Reverse **iOS:** ```swift let response = try await KoardMerchantSDK.shared.reverse( transactionId: transactionId, eventId: eventId ) ``` **Android:** ```kotlin val transaction = sdk.reverse( transactionId = transactionId, eventId = eventId ).getOrThrow() ``` ## Partial Reverse Reduce the authorized amount without voiding the entire transaction: **iOS:** ```swift let response = try await KoardMerchantSDK.shared.reverse( transactionId: transactionId, amount: 5000, // reduce hold by $50 eventId: eventId ) ``` **Android:** ```kotlin val transaction = sdk.reverse( transactionId = transactionId, amount = 5000, eventId = eventId ).getOrThrow() ``` ## Surcharge Handling When reversing a surcharged transaction, the [surcharge](/docs/appendix/glossary#surcharge "Fee on eligible credit-card transactions to offset processing costs (not debit).") is handled according to the processor's reversal rules. Verify the returned reversed amount, especially for partial reversals. ## When to Use Reverse vs. Refund | | Reverse | Refund | | ---------------- | ----------------------------------------- | ------------------------------------------------- | | **Timing** | Before settlement | After settlement | | **Availability** | Hold release timing depends on the issuer | Credit timing depends on the processor and issuer | | **Surcharge** | Verify the returned reversal amount | Verify the processor's refund treatment | | **Use case** | Cancel, customer changed mind | Post-settlement return | ## Parameters | Parameter | Type | Required | Description | | --------------- | --------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------ | | `transactionId` | `String` | Yes | Transaction to reverse | | `amount` | `Int?` | No | Partial reverse amount in minor units. Omit for full void. | | `eventId` | `String?` | No | [Idempotency](/docs/appendix/glossary#idempotency "Safe retry pattern using event_id so network failures do not double-charge.") key | ## See Also * [Refund](/docs/payments/methods/refund) — Return funds after settlement * [Sale](/docs/payments/methods/sale) — Original payment * [Preauth](/docs/payments/methods/preauth) — Authorization hold # Refund A refund returns funds on an existing captured or settled payment where the processor supports it. Availability before settlement and supported refund amounts depend on the merchant configuration and processor rules. > For voiding a transaction **before** settlement, see [Reverse](/docs/payments/methods/reverse). Persist a separate `eventId` with your order before each operation below, and pass that saved value to the SDK. On a timeout or lost response, reconcile that ID instead of generating a new one. See [Idempotency and Outcome Recovery](/docs/payments/idempotency). Run Android reference operations from `withContext(Dispatchers.IO)`. The examples unwrap `Result` with `getOrThrow()`; handle thrown errors at the UI boundary. Inspect the returned transaction status and operation amounts before displaying success. See the [Android operation helpers](/docs/setting-up-the-android-sdk/running-payments#post-reader-operations). ## Full Refund **iOS:** ```swift let response = try await KoardMerchantSDK.shared.refund( transactionId: transactionId, amount: 12875, eventId: eventId ) ``` **Android:** ```kotlin val transaction = sdk.refund( transactionId = transactionId, amount = 12875, eventId = eventId ).getOrThrow() ``` ## Partial Refund Refund a portion of the original transaction: **iOS:** ```swift let response = try await KoardMerchantSDK.shared.refund( transactionId: transactionId, amount: 5000, // refund $50 of the original eventId: eventId ) ``` **Android:** ```kotlin val transaction = sdk.refund( transactionId = transactionId, amount = 5000, eventId = eventId ).getOrThrow() ``` ## Tap-Based Refund (iOS) For card-present refunds where the customer taps their card: ```swift let response = try await KoardMerchantSDK.shared.refund( transactionId: transactionId, amount: 12875, eventId: eventId, withTap: true ) ``` > Tap-based refunds require the card reader to be prepared. The customer taps their card to confirm the refund. ## Tap-Based Refund (Android) Use the Activity-based refund overload from a UI coroutine, with SDK work on IO: ```kotlin val flow = withContext(Dispatchers.IO) { sdk.refund( activity = activity, transactionId = transactionId, amount = 12875, withTap = true, eventId = eventId ) }.flowOn(Dispatchers.IO) flow.collect { response -> handleTransactionEvent(response) // Handler in Android Running Payments } ``` `refundEmv(...)` is also supported as a tap-refund alias. Reference-based `sdk.refund` does not require an Activity or a new card tap. Wrap invocation and collection in the [Running Payments](/docs/setting-up-the-android-sdk/running-payments) error-handling pattern. ## Surcharge Handling When refunding a surcharged transaction, the [surcharge](/docs/appendix/glossary#surcharge "Fee on eligible credit-card transactions to offset processing costs (not debit).") is handled according to the processor's refund rules. Do not assume every processor prorates it automatically. Specify the intended refund amount and reconcile the returned refund and surcharge amounts. ### Fiserv Nashville Global refund breakdown The `amount` you request is the **total to return**, including any tax and surcharge portion. Koard does not add another surcharge refund on top. For this route, Koard calculates the refund event's breakdown from the original payment; it does not rely on the processor to return that split. Read the event with `transaction_type: refund` and the matching `event_id` in the operation response or `transaction.refund` webhook: | Field | Meaning on the refund event | | ------------------------ | -------------------------------------------- | | `total_amount` | Amount returned by this refund | | `surcharge_amount` | Surcharge portion of this refund | | `tax_amount` | Tax portion of this refund | | `subtotal`, `tip_amount` | Remaining components of this refund | | `refunded` | Cumulative amount refunded across operations | These component fields describe the refund event, not the original sale. The SDK properties are `surchargeAmount` and `taxAmount`. A later transaction lookup can return a newer event, so retain the refund's `event_id` and use history or event lookup when reconciling it. The surcharge portion is `round_half_even(original_surcharge * refund_total / original_total)`, using the fee actually collected. Tax, tip and subtotal divide the remaining amount proportionally, with cent remainders allocated so the components sum to the refund total. The final refund returns any remaining surcharge cents from prior rounding. For example, a payment with $100 subtotal, $10 tax, $20 tip and a $3 subtotal-based surcharge totals $133. A $66.50 refund returns $50 subtotal, $5 tax, $10 tip and **$1.50 surcharge**. ## Parameters | Parameter | Type | Required | Description | | --------------- | ----------------------------------------------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | `transactionId` | `String` | Yes | Original transaction to refund | | `amount` | `Int?` for reference refunds; `Int` for Android tap refunds | Required for tap refunds | Minor currency units; omit or pass null/nil for a full reference refund | | `eventId` | `String?` | No | [Idempotency](/docs/appendix/glossary#idempotency "Safe retry pattern using event_id so network failures do not double-charge.") key | | `withTap` | iOS `Bool`, Android `Boolean` | For the Android Activity overload | Pass true for a card-present refund; iOS defaults to false | | `activity` | `Activity` | For Android tap refunds | Activity for a card-present refund; not required for reference-based `refund` | ## See Also * [Reverse](/docs/payments/methods/reverse) — Void before settlement * [Sale](/docs/payments/methods/sale) — Original payment * [Payment Lifecycle](/docs/payments/payment-lifecycle) — End-to-end flow # Payment Lifecycle Koard facilitates card acceptance and routes payment requests to the merchant's configured processor. A payment can contain several events: the original sale or preauth, followed by capture, tip adjustment, reversal, or refund. ## Before Taking a Payment Authenticate the merchant, load its locations, and select the active location. **Provide a location selector in your app's settings.** The location's linked terminal determines the processor configuration and merchant/terminal identifiers used for that merchant. Show the selected location to the operator and block payments until a valid location is selected. See [Active location and terminal routing](/docs/getting-started-with-koard/setting-up-the-merchant#active-location-and-terminal-routing). Prepare the reader using the relevant [iOS](/docs/setting-up-the-ios-sdk/running-payments) or [Android](/docs/setting-up-the-android-sdk/running-payments) guide. Persist an `event_id` before each operation that exposes one. ## Operations | Operation | iOS SDK | Android SDK | REST follow-up | |---|---|---|---| | [Sale](sale.md) | `sale` | `sale` | Initiation is handled by the reader SDK | | [Preauth](preauth.md) | `preauth` | `preauth` | Initiation is handled by the reader SDK | | [Capture](capture.md) | `capture` | `capture` | `POST /v3/payments/{transaction_id}/capture` | | [Incremental auth](incremental-auth.md) | `auth` | `incrementalAuth` | `POST /v3/payments/{transaction_id}/auth` | | [Tip adjust](tip-adjust.md) | `tipAdjust` | `adjust` | `POST /v1/payments/{transaction_id}/adjust` | | [Reverse](reverse.md) | `reverse` | `reverse` | `POST /v1/payments/{transaction_id}/reverse` | | [Refund](refund.md) | `refund`, optionally `withTap: true` | Reference `refund`, or Activity-based `refund(..., withTap = true)` / `refundEmv` | `POST /v1/payments/{transaction_id}/refund` for a reference refund | The released SDKs do not expose identical arguments for every operation. Use each platform's example. Initial card-present requests use SDK-generated encrypted reader data; a PAN or a generic test-card number cannot replace that data. ## Transaction Identifiers - `transaction_id` groups a payment's lifecycle and is used for follow-up operations. - `event_id` identifies an individual operation. Retain it with your order so an uncertain outcome can be recovered. - `GET /v1/transactions/event/{event_id}` reads the event visible to your account using an API key with `payments:read`. An HTTP success alone does not establish that funds were captured. Inspect `status`, the authorized amount, and the processor response, including partial approvals. ## REST Status Values | Status | Meaning | |---|---| | `pending` | Outcome or external input is still pending | | `surcharge_pending` | Cardholder surcharge confirmation is required | | `authorized` | Funds are held; capture or reverse as appropriate | | `captured` | Sale or capture succeeded; settlement follows | | `settled` | Settlement was recorded | | `declined` | Payment was declined | | `error` | An error was recorded; reconcile any uncertain processor outcome | | `refunded` | A refund event succeeded; inspect amounts for partial refunds | | `reversed` | A reversal event succeeded | | `canceled` | The flow was canceled | These are REST values. SDK enums can use platform-specific names, such as iOS `surchargePending`, and include reader or UI states. A typical preauth progresses from `authorized` to `captured` and then `settled`. A sale reaches `captured` directly. Refunds and reversals are separate events on the original transaction; use event history and amounts when reconciling. ## Amounts and Breakdowns Use integer minor currency units for amounts, for example `1000` for USD $10.00. Keep the final subtotal, tax, tip, and surcharge consistent with the total sent to the processor. For a surcharge, specify **one of** `amount` or `percentage`, or use `bypass: true` without either. Percentage surcharge values use whole percent units, for example `3.5` for 3.5%. Fixed surcharge amounts avoid ambiguity when you have already calculated the fee. REST tip adjustment is different: its percentage is an integer from 1 through 100. If the final capture exceeds the current authorized amount, obtain any required incremental authorization first. Processor and card rules govern supported adjustments and settlement cutoffs. ## Recovering an Uncertain Outcome A timeout, lost response, or server error can occur after the processor receives a payment. Do not start a second sale with a new event ID to compensate. Look up the persisted event ID, reconcile the outcome, and follow [Idempotency](idempotency.md). A lookup returning 404 alone does not prove that an in-flight request never reached the processor. ## Webhooks and Settlement Subscribe to the operation event names documented in [Available Events](/docs/webhooks/available-events): `transaction.create`, `transaction.authorize`, `transaction.sale`, `transaction.capture`, `transaction.increment`, `transaction.tip_adjust`, `transaction.cancel`, `transaction.reverse`, and `transaction.refund`. The webhook body is the transaction object; delivery metadata is provided in the webhook headers. Verify signatures and deduplicate deliveries. Event names identify operations, so also inspect the transaction status and amounts. Settlement is reported separately through [batch APIs and events](/docs/batch-and-settlements/running-batches). The supported Fiserv integration manages batches automatically; customers must not manually open, close, or edit those batches. # Batch and Settlements Learn how to manage batch processing and settlement operations with Koard's payment platform. [Get started with Koard](/docs/introduction) Koard provides comprehensive batch processing and settlement management for transactions processed through our system. Whether you're an enterprise [ISV](/docs/appendix/glossary#isv "Software company building payment or POS applications on Koard."), [PSP](/docs/appendix/glossary#psp "Company that serves multiple merchants with payment processing."), or building a payment platform, Koard offers flexible solutions to meet your settlement needs. **What you learn** In this guide, you'll learn: * How Koard's batch processing system works * The two main workflows for different business types * How to retrieve batch information and settlement data * Best practices for enterprise vs. platform implementations * How to integrate with Koard's settlement APIs ## Before you begin This guide covers batch processing and settlement management in Koard. For a better understanding of how to process payments, see our [Running Payments guide](/docs/setting-up-the-ios-sdk/running-payments). If you're ready to start accepting payments, see our [Getting Started guide](/docs/introduction). ## Batch Processing Overview Koard can run batches and settlements for transactions processed through our system. Our platform handles all scheduling and retries automatically, providing you with: * **Batch Lists**: Retrieve comprehensive batch information * **Batch Components**: Detailed breakdown of batch contents * **[Settlement](/docs/appendix/glossary#settlement "Transfer of funds from the payment processor to the merchant account after batching.") Breakdown**: Success and failure analysis * **Automated Scheduling**: Koard handles all timing and retries * **Per-Merchant Batching**: Batches are created per unique [MID](/docs/appendix/glossary#mid "Processor-assigned merchant identifier used for routing and batching.") and [TID](/docs/appendix/glossary#tid "Processor-assigned per-terminal identifier; batches are typically unique per MID/TID.") combination ## Two Main Workflows Koard supports two distinct workflows based on your business model and technical requirements. ### Enterprise ISVs and PSPs **Recommended approach**: Use your own batching solution For enterprise ISVs and PSPs with existing batching infrastructure, we recommend maintaining your own batch processing and settlement management: * **Append to Existing Batches**: Add Koard transactions to your already open batches * **Trigger Batch Closure**: Close batches using your existing system * **Inform Koard**: Update Koard's systems when settlements occur * **Avoid Duplicate Issues**: Prevents duplicate batch IDs and synchronization problems **Important**: For TSYS, Fiserv, and Elavon processors, there's a high risk of duplicate batch IDs, out-of-order synchronization, and missing/invalid tags that may fail batches when using Koard's batching system. **Exception**: If you've created specific MID and TID combinations for [Tap to Pay](/docs/appendix/glossary#tap-to-pay "Apple contactless acceptance on iPhone without extra hardware.") through Koard, you can safely use Koard's batching system without the above concerns. ### Everyone Else **Recommended approach**: Use Koard's settlement system If Koard is your sole authorization layer and you don't have existing batching infrastructure: * **Full Settlement Management**: Let Koard handle all batch processing * **Automated Scheduling**: Koard manages timing and retries * **Simplified Integration**: Single API for all settlement operations * **Comprehensive Reporting**: Built-in analytics and monitoring ## Key Features | Feature | Description | Enterprise ISVs | Platform Users | | ------------------------- | ------------------------------------- | ------------------------- | --------------------------- | | **Batch Creation** | Automatic batch creation per MID/TID | Use your own system | Koard handles automatically | | **Settlement Scheduling** | Automated timing and retries | Your existing schedule | Koard manages timing | | **Error Handling** | Robust retry logic and error recovery | Your error handling | Koard handles retries | | **Reporting** | Batch and settlement analytics | Your reporting system | Koard provides reports | | **API Access** | RESTful APIs for batch management | Limited to status updates | Full API access | ## Getting Started 1. **Determine Your Workflow**: Choose between enterprise or platform approach 2. **Set Up Integration**: Configure your chosen workflow 3. **Test Batch Processing**: Verify your implementation 4. **Monitor Operations**: Track batch and settlement status ## Best Practices ### For Enterprise ISVs and PSPs * **Maintain Existing Batches**: Don't create separate batches for Koard transactions * **Sync Settlement Data**: Keep Koard informed of settlement status * **Handle Edge Cases**: Implement proper error handling for processor-specific issues * **Monitor for Duplicates**: Watch for duplicate batch IDs across systems ### For Platform Users * **Use Koard APIs**: Leverage Koard's comprehensive batch management * **Monitor Settlement Status**: Track batch processing through Koard's dashboard * **Implement [Webhooks](/docs/appendix/glossary#webhook "HTTPS POST callback from Koard when account or payment events occur.")**: Set up real-time notifications for settlement events * **Regular Reconciliation**: Verify settlement data against your records ## Integration Points * **API Integration**: RESTful APIs for batch management and status updates * **Webhook Notifications**: Real-time batch and settlement status updates * **Dashboard Monitoring**: Visual tracking of batch processing and settlements * **Reporting Tools**: Comprehensive analytics and settlement reports ## See also This wraps up the batch and settlements overview. See the links below for detailed implementation guides: * [Running Batches](/docs/batch-and-settlements/running-batches) - Step-by-step batch implementation * [Running Payments](/docs/setting-up-the-ios-sdk/running-payments) - Payment processing fundamentals * [Setting up Webhooks](/docs/webhooks/setting-up-webhooks) - Configure webhooks to receive real-time batch and settlement event notifications ## Processor-specific batch management TSYS and Elavon support manual terminal-capture batches. Worldpay supports manual and scheduled release of existing batches but does not support explicit batch opening. The managed Fiserv integration runs automatically and does not support manual batch operations. * [Running Batches](/docs/batch-and-settlements/running-batches) * [Fiserv Automatic Settlement](/docs/batch-and-settlements/fiserv-settlement) # Running Batches Learn how to create, manage, and process batches with Koard's payment platform. [Get started with Koard](/docs/introduction) Koard provides comprehensive batch processing capabilities for managing transactions and settlements. Whether you choose to let Koard handle batches automatically or manage them yourself, this guide covers all the essential operations and best practices. **What you learn** In this guide, you'll learn: * How to choose between Koard-managed and self-managed batch processing * Core batch operations: opening, closing, and editing batches * Required fields for self-managed batch processing * [Webhook](/docs/appendix/glossary#webhook "HTTPS POST callback from Koard when account or payment events occur.") integration for real-time batch updates * Processor-specific considerations and constraints * Best practices for batch management and error handling ## Before you begin This guide covers batch processing operations in Koard. For a better understanding of batch concepts, see our [Batch and Settlements overview](/docs/batch-and-settlements/overview). If you're ready to start processing payments, see our [Running Payments guide](/docs/setting-up-the-ios-sdk/running-payments). ## Processor Capabilities | Configuration | Manual operations | Scheduling | | -------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------- | | TSYS terminal capture | Open, close, and edit supported batches | Editable terminal batch schedule | | Elavon terminal capture | Open, close, and edit supported batches | Editable terminal batch schedule | | Worldpay | Manual close/release of an existing open batch; explicit open is not supported | Scheduled release is supported; Worldpay creates batches | | Managed Fiserv integration | No manual open, close, or edit | Automatic fixed cycle; monitor through webhooks and read APIs | Use the selected terminal's supported configuration. The presence of a general batch endpoint does not mean every processor supports that operation. See [Fiserv automatic settlement](/docs/batch-and-settlements/fiserv-settlement) and [Automated Batch Scheduling](/docs/batch-and-settlements/automated-batch-scheduling). ## Two Batch Processing Approaches Koard supports two distinct approaches for batch processing based on your technical requirements and business model. ### Via Koard (Recommended) **Best for**: [Tap to Pay](/docs/appendix/glossary#tap-to-pay "Apple contactless acceptance on iPhone without extra hardware.") terminals with specific gateway configurations Koard can handle batches entirely for a [MID](/docs/appendix/glossary#mid "Processor-assigned merchant identifier used for routing and batching.")/[TID](/docs/appendix/glossary#tid "Processor-assigned per-terminal identifier; batches are typically unique per MID/TID.") combination. This approach is recommended if you have specific terminals configured on the gateway for Tap to Pay. **Key benefits:** * **Automatic Management**: Koard handles all batch operations automatically * **Status Tracking**: Automatic transaction status tracking and reconciliation * **Error Handling**: Built-in retry logic and error recovery * **Webhook Integration**: Real-time updates for all batch events ### Self-Managed Batches **Best for**: Enterprise [ISVs](/docs/appendix/glossary#isv "Software company building payment or POS applications on Koard.") and [PSPs](/docs/appendix/glossary#psp "Company that serves multiple merchants with payment processing.") with existing batch infrastructure If you prefer to manage batches yourself, you'll need to handle the core batch operations and maintain the required transaction data. **Key requirements:** * **Required Fields**: Maintain all necessary transaction data * **Webhook Integration**: Process transaction events for batch management * **Sequential Ordering**: Maintain proper batch number sequencing * **Error Handling**: Implement retry logic for failed operations ## Core Batch Operations Koard provides several core functions that you can apply to batches and transactions: | Operation | Description | When to Use | API Endpoint | | ------------------- | ------------------------------------------------------------ | ------------------------------------------ | ----------------------------------- | | **List Batches** | Retrieve paginated list of batches | Viewing batch history, filtering by status | `GET /v1/batches` | | **Download Report** | Get a temporary download URL for an available managed report | Reconciliation | `GET /v1/batches/{batch_id}/report` | | **Get Batch** | Retrieve specific batch details | View batch status and transactions | `GET /v1/batches/{batch_id}` | | **Open Batch** | Create a new batch for transactions | Start of business day or when needed | `POST /v1/batches/open` | | **Close Batch** | Finalize and submit batch for processing | End of business day or when ready | `POST /v1/batches/{batch_id}/close` | | **Edit Batch** | Add or remove transactions from open batch | Before closing the batch | `PUT /v1/batches/{batch_id}/edit` | ### Listing Batches ```bash GET /v1/batches?limit=50&offset=0&statuses=open ``` **Query Parameters:** * `account_id` (optional): Filter by owning account * `terminal_id` (optional): Filter by terminal ID * `statuses` (optional): Filter by batch lifecycle statuses — accepts multiple values (open, closed, submitted, accepted, partially\_accepted, rejected, cancelled) * `processor_config_id` (optional): Filter by processor config ID * `limit` (optional): Maximum items per page (1-500, default: 50) * `offset` (optional): Number of items to skip (default: 0) ### Getting a Batch ```bash GET /v1/batches/{batch_id}?include_transactions=true ``` **Query Parameters:** * `include_transactions` (optional): If true, embed transactions with the batch (default: false) ### Opening a Batch ```bash POST /v1/batches/open { "terminal_id": "tid_987654321", "processor_batch_id": "batch_001" } ``` **Required Fields:** * `terminal_id`: The terminal identifier * `processor_batch_id` (optional): Assigned processor batch number override where supported. For TSYS, Koard computes the number (001 through 999) and ignores a supplied override. ### Closing a Batch ```bash POST /v1/batches/{batch_id}/close ``` No request body is required. The batch must be in an `open` status, and the configuration must support manual close/release. This operation is unavailable for the managed Fiserv integration. The response body is a Batch object for all processor settlement outcomes: HTTP 200 for acceptance, 409 for a duplicate batch response, and 502 for a non-accepted processor response. Inspect `status`, `response`, and `rejection_reason`; these 409/502 responses are not the usual error envelope. Reconcile before retrying a submission. ### Editing an Open Batch Only open batches can be edited. ```bash PUT /v1/batches/{batch_id}/edit { /*"added_transactions": ["17279734-fa7b-4f26-a945-19a8a98b6258"], "removed_transactions": [],*/ "processor_batch_id": "6" } ``` **Body Fields (all optional):** * `processor_batch_id`: The processor batch ID * `added_transactions`: List of transaction IDs to add to the batch * `removed_transactions`: List of transaction IDs to remove from the batch ## Self-Managed Batch Requirements If you choose to run batches yourself, you'll need to maintain these essential fields for each transaction: ### Required Transaction Fields | Field | Description | Example | | -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | | **Approval Code** | Authorization approval code | `123456` | | **Response Code** | Transaction response code | `00` | | **Transaction Identifier** | Unique transaction ID | `txn_abc123def456` | | **Local Date/Time** | Transaction timestamp | `2024-01-15T14:30:00Z` | | **Amounts** | Auth, settled, tip, [surcharge](/docs/appendix/glossary#surcharge "Fee on eligible credit-card transactions to offset processing costs (not debit)."), cashback | `{"auth": 1000, "settled": 1000}` | | **ACI/Void/Reversal Indicators** | Transaction type indicators | `{"aci": "Y", "void": "N"}` | ### Webhook Integration Every transaction processed through Koard triggers a webhook event. Upon delivery, you'll receive: * **Transaction Data**: All required fields for batch processing * **TLV Tags**: General TLV tags from the payment device * **Status Information**: Real-time transaction status updates * **Batch Context**: Information about which batch the transaction belongs to ```json { "event": "transaction.processed", "data": { "transactionId": "txn_abc123def456", "approvalCode": "123456", "responseCode": "00", "amount": { "auth": 1000, "settled": 1000, "tip": 0 }, "timestamp": "2024-01-15T14:30:00Z", "batchId": "batch_xyz789", "tlvTags": { "aci": "Y", "void": "N" } } } ``` ## Batch Lifecycle Management ### Batch Statuses A batch progresses through several statuses during its lifecycle: | Status | Description | Transitions To | | ----------------------- | -------------------------------------------------- | --------------------------------------- | | **open** | Batch is open and accepting transactions | closed | | **closed** | Batch has been closed and submitted for settlement | submitted, rejected | | **submitted** | Batch has been submitted to the processor | accepted, partially\_accepted, rejected | | **accepted** | Batch has been fully accepted by the processor | (terminal state) | | **partially\_accepted** | Some transactions were accepted, others rejected | (terminal state) | | **rejected** | Batch was rejected by the processor | (terminal state) | | **cancelled** | Batch was cancelled before settlement | (terminal state) | ### Automatic Transaction Addition When a batch is open on a terminal: * **Captured Transactions**: Automatically added to the open batch * **Refunds**: Automatically added to the open batch * **Real-time Updates**: Webhook events provide immediate status updates ### Batch Closure Process 1. **Close at Any Time**: Batches can be closed at any point (typically end of business) 2. **Constraint**: You can only close an open batch 3. **Rejected Batches**: If a batch is rejected or partially approved: * Add all rejected transactions to a new open batch * Close the new batch again * Maintain batch number sequential order ### Editing Open Batches You can edit an open batch by: * **Adding Transactions**: Include new transactions before closing * **Removing Transactions**: Remove transactions if needed * **Constraint**: Only open batches can be edited ## Processor-Specific Considerations ### TSYS Constraints TSYS has specific batch ID requirements that affect batch management: | Constraint | Description | Impact | | ------------------ | --------------------------------- | ------------------------------------ | | **Batch ID Range** | Numbered batch IDs from 1-999 | Limited batch ID availability | | **Sliding Window** | 5-day sliding window for reuse | Batch IDs can be reused after 5 days | | **Uniqueness** | Unique per MID/TID combination | Prevents duplicate batch IDs | | **Error Handling** | Reusing batch ID results in error | Requires proper ID management | ### Batch ID Management * **Default Behavior**: Koard automatically configures batch IDs * **Override Option**: You can override batch IDs when running your own batches * **Webhook Integration**: Set batch ID via webhook when managing batches yourself * **Sequential Order**: Maintain proper batch number sequencing ### Splitting Batches **Not Recommended**: Splitting batches is not recommended due to various constraints imposed by processors like TSYS, Fiserv, and Elavon. **Best Practice**: Each MID/TID should have one batch per day to avoid potential duplicate batch ID issues. ## Error Handling and Recovery ### Failed/Rejected Batches When batches fail or are rejected: 1. **Status Information**: Koard returns detailed status information 2. **Failure Details**: Specific information about what failed 3. **Retry Logic**: Automatic retry mechanisms for recoverable errors 4. **Manual Approval**: Rejected batches can be manually approved via the portal on TSYS ### Monitoring and Alerts * **Real-time Status**: Monitor batch processing status in real-time * **Error Notifications**: Receive alerts for batch failures * **Webhook Events**: Subscribe to batch status change events * **Dashboard Monitoring**: Visual tracking of batch operations ## Best Practices ### Batch Timing * **End of Business**: Close batches at the end of each business day * **Peak Hours**: Avoid processing batches during peak transaction times * **Timezone Considerations**: Consider your merchant's timezone for batch windows * **Business Hours**: Process batches during business hours for faster support ### Error Handling * **Retry Logic**: Implement retry logic for failed batch operations * **Partial Failures**: Handle cases where some transactions in a batch fail * **Monitoring**: Set up alerts for batch processing failures * **Recovery Procedures**: Have clear procedures for handling rejected batches ### Performance Optimization * **Batch Size**: Optimize batch sizes for your transaction volume * **Sequential Processing**: Maintain proper batch number sequencing * **Resource Management**: Monitor system resources during batch processing * **Webhook Processing**: Ensure reliable webhook event processing ## See also This wraps up the running batches guide. See the links below for related information: * [Batch and Settlements Overview](/docs/batch-and-settlements/overview) - High-level batch concepts * [Running Payments](/docs/setting-up-the-ios-sdk/running-payments) - Payment processing fundamentals * [Setting up Webhooks](/docs/webhooks/setting-up-webhooks) - Webhook configuration * [Available Events](/docs/webhooks/available-events) - Webhook event reference # Automated Batch Scheduling ## Automated Batch Scheduling Koard supports automated batch close or release scheduling per terminal for TSYS, Elavon, and Worldpay. TSYS and Elavon support explicit batch opening; Worldpay creates batches through its host-capture flow. The supported Fiserv integration uses a fixed processor-managed cycle. ### Supported Processors | Processor | Supported | Notes | | -------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | **TSYS** | ✅ | Full support. Batch numbers auto-managed (001-999). | | **Elavon** | ✅ | Full support via ViaConex TC 920/921/929. | | **Worldpay** | ✅ | Scheduled batch release through the settlement interface; explicit open is unavailable. | | Fiserv managed integration | Automatic, fixed | Koard tracks the processor-managed cycle; customers cannot change the schedule or manually open, close, or edit batches. See [Fiserv settlement](/docs/batch-and-settlements/fiserv-settlement). | | Payroc | ❌ | Not supported for automated scheduling. | ### How It Works 1. **Configure a schedule** on the terminal via `PUT /v2/terminals/{terminal_id}` 2. **Koard's scheduler** runs every minute, checking for terminals due for batch close 3. When due, Koard **closes or releases the current batch**. A new batch is opened where the processor supports explicit open; Worldpay manages batch creation. 4. If a close fails, Koard **retries up to 10 times** with backoff 5. On persistent failure, a **webhook error event** is sent ### Setting Up a Schedule Add a `batch_schedule` field to your terminal update: ```json PUT /v2/terminals/{terminal_id} { "batch_schedule": { "timezone": "US/Eastern", "is_active": true, "schedule": [ { "day": "MON", "times": ["23:00"] }, { "day": "TUE", "times": ["23:00"] }, { "day": "WED", "times": ["23:00"] }, { "day": "THU", "times": ["23:00"] }, { "day": "FRI", "times": ["23:00"] }, { "day": "SAT", "times": ["23:00"] } ] } } ``` #### Response The terminal response includes confirmation that the schedule was saved: ```json { "terminal_id": "term-abc123", "name": "Front Counter POS", "mid": "886000001130", "tid": "00000001", "processor_config_id": "cfg-tsys-001", "status": "active", "var_sheet": { "applicationId": "B001" } } ``` ### Schedule Format #### Days Use 3-letter day codes: | Code | Day | | ----- | --------- | | `MON` | Monday | | `TUE` | Tuesday | | `WED` | Wednesday | | `THU` | Thursday | | `FRI` | Friday | | `SAT` | Saturday | | `SUN` | Sunday | #### Times Times are in 24-hour `HH:MM` format. You can set **multiple closes per day**: ```json { "day": "WED", "times": ["12:00", "18:00", "23:00"] } ``` This closes the batch at noon, 6pm, and 11pm on Wednesdays. #### No Close on a Day Simply omit the day from the schedule. If Saturday and Sunday are not listed, no batch close happens on weekends. ### Timezones **DST vs Fixed Timezones**: Choose carefully between DST-aware and fixed-offset timezones. Most merchants want DST-aware timezones so the batch close follows "wall clock" time. #### DST-Aware Timezones (Recommended) These follow daylight saving time transitions. `23:00 US/Eastern` means 11pm EDT in summer and 11pm EST in winter. | Timezone | Description | | ------------- | -------------------------- | | `US/Eastern` | Eastern Time (New York) | | `US/Central` | Central Time (Chicago) | | `US/Mountain` | Mountain Time (Denver) | | `US/Pacific` | Pacific Time (Los Angeles) | | `US/Alaska` | Alaska Time | | `US/Hawaii` | Hawaii Time (no DST) | | `US/Arizona` | Arizona Time (no DST) | You can also use full IANA zone names like `America/New_York`, `America/Chicago`, etc. #### Fixed-Offset Timezones These **never** change for DST. Use only if you want a fixed UTC offset year-round. | Timezone | UTC Offset | Notes | | -------- | ------------- | ------------------------------------ | | `EST` | UTC-5 always | Does **not** switch to EDT in summer | | `MST` | UTC-7 always | Does **not** switch to MDT in summer | | `HST` | UTC-10 always | Same as US/Hawaii | | `UTC` | UTC+0 always | Universal Coordinated Time | #### Example: DST Impact A batch close at `23:00 US/Eastern`: * **Winter (EST)**: Fires at 04:00 UTC * **Summer (EDT)**: Fires at 03:00 UTC A batch close at `23:00 EST`: * **Always**: Fires at 04:00 UTC (even in summer when "wall clock" Eastern time is EDT) ### Managing Schedules #### Update an Existing Schedule ```json PUT /v2/terminals/{terminal_id} { "batch_schedule": { "timezone": "US/Pacific", "schedule": [ { "day": "MON", "times": ["22:00"] }, { "day": "FRI", "times": ["14:00", "22:00"] } ] } } ``` #### Pause Scheduling (Keep Config) Send the current timezone and schedule with `is_active: false` to pause an editable schedule. This object uses create/update defaults, so sending only `is_active` can replace the timezone and schedule with defaults. Preserve the full configuration when pausing: ```json PUT /v2/terminals/{terminal_id} { "batch_schedule": { "timezone": "US/Pacific", "is_active": false, "schedule": [{ "day": "MON", "times": ["22:00"] }] } } ``` #### Resume Scheduling ```json PUT /v2/terminals/{terminal_id} { "batch_schedule": { "timezone": "US/Pacific", "is_active": true, "schedule": [ { "day": "MON", "times": ["23:00"] }, { "day": "TUE", "times": ["23:00"] } ] } } ``` **Important**: An active schedule must have at least one day with times configured. Setting `is_active: true` with an empty schedule will return an error. #### Remove Schedule Entirely (Back to Manual) Set `batch_schedule` to `null`: ```json PUT /v2/terminals/{terminal_id} { "batch_schedule": null } ``` After removal, the terminal returns to manual batch management. #### Updates Without batch\_schedule Updating other terminal fields (name, [MID](/docs/appendix/glossary#mid "Processor-assigned merchant identifier used for routing and batching."), var\_sheet, etc.) does **not** affect the schedule: ```json PUT /v2/terminals/{terminal_id} { "name": "New Terminal Name" } ``` The existing batch schedule is preserved. ### Switching Between Auto and Manual #### Switching from Auto to Manual To switch a terminal back to manual batch management, remove the schedule: ```json PUT /v2/terminals/{terminal_id} { "batch_schedule": null } ``` **Important**: When switching to manual, the current open batch stays open. You are now responsible for: * Closing or releasing batches manually (`POST /v1/batches/{batch_id}/close`) * For TSYS and Elavon, opening new batches manually (`POST /v1/batches/open`) * For Worldpay, allowing the processor to create the next batch; do not call the open endpoint * Managing future batch closes or releases after removing the Koard schedule #### Switching from Manual to Auto Enable a schedule on an existing terminal: ```json PUT /v2/terminals/{terminal_id} { "batch_schedule": { "timezone": "US/Central", "is_active": true, "schedule": [ { "day": "MON", "times": ["22:00"] }, { "day": "TUE", "times": ["22:00"] }, { "day": "WED", "times": ["22:00"] }, { "day": "THU", "times": ["22:00"] }, { "day": "FRI", "times": ["22:00"] } ] } } ``` If the terminal already has an open batch, the scheduler closes or releases it at the next scheduled time. TSYS and Elavon then open a new batch; Worldpay creates its next batch through the host-capture flow. ### Closing a Batch Early For TSYS, Elavon, and Worldpay, you can close or release an eligible batch early while automated scheduling is enabled. This does not apply to the fixed cycle managed by Fiserv: ```bash POST /v1/batches/{batch_id}/close ``` **TSYS and Elavon require a new open batch after an early close.** Call `POST /v1/batches/open` before the next payment. **Worldpay does not support explicit batch opening**; its processor creates the next batch when the next transaction is authorized. Do not call the open endpoint for Worldpay. For **TSYS and Elavon**, when the scheduler fires later at its scheduled time: * If it finds an **open batch with transactions**, it closes and reopens normally * If it finds an **open batch with no transactions** (e.g., you just opened it), it cancels the empty batch and opens a fresh one * If it finds **no open batch** (e.g., you closed early and didn't reopen), it opens a new one For TSYS and Elavon, the scheduler can recover a missing open batch on its next run, but payments remain blocked until a batch is open. For Worldpay, continue taking payments through host capture and release the resulting batch at the configured time; no open request is required. #### TSYS or Elavon Example: Early Close at 3pm, Scheduled Close at 11pm 1. **3:00 PM** — You close the batch early via API 2. **3:01 PM** — You open a new batch via API 3. **3:01 PM – 11:00 PM** — Transactions accumulate in the new batch 4. **11:00 PM** — Scheduler fires, closes the batch (with transactions), opens a new one If you forget to reopen at step 2, the scheduler at 11pm will detect no open batch and open one for you — but any transactions between 3:01 PM and 11:00 PM will have failed because there was no open batch. ### TSYS-Specific Behavior #### Batch Number Auto-Management TSYS batch numbers must be between **001-999** and cannot be reused within **5 consecutive days**. Koard handles this automatically: 1. **On open**: Queries the last closed batch for the terminal and increments 2. **On close**: Verifies no conflicting batch number, auto-increments if needed 3. **On duplicate (QD)**: Automatically retries with the next batch number (up to 10 attempts) #### Batch Number Wrapping When the batch number reaches 999, it wraps around to 001. ### Error Handling | Scenario | Koard's Response | | -------------------------------- | -------------------------------------------------------------------- | | Processor unavailable | Retries up to 10 times with backoff | | Duplicate batch number (TSYS QD) | Auto-increments and retries | | All retries exhausted | Sends `batch.rejected` webhook, logs error | | No open batch to close | Skips close; opens a batch for processors that support explicit open | ### Webhook Events When using automated scheduling, you'll receive the standard batch webhook events: | Event | When | | ----------------- | ----------------------------------------------------------------------- | | `batch.submitted` | Batch sent to processor | | `batch.accepted` | Processor accepted the batch | | `batch.rejected` | Processor rejected the batch, or the scheduler failed after all retries | | `batch.opened` | A new batch is explicitly opened, such as for TSYS or Elavon | ### Permissions The following roles can view and manage batch schedules: | Role | View | Create/Edit | Delete | | ---------------------------------------------------------------------------------------------------- | ---- | ----------- | ------ | | [PSP](/docs/appendix/glossary#psp "Company that serves multiple merchants with payment processing.") | ✅ | ✅ | ✅ | | Partner | ✅ | ✅ | ✅ | | Merchant | ✅ | ✅ | ✅ | ### See also * [Batch and Settlements Overview](/docs/batch-and-settlements/overview) - Batch concepts * [Running Batches](/docs/batch-and-settlements/running-batches) - Manual batch management * [Setting up Webhooks](/docs/webhooks/setting-up-webhooks) - [Webhook](/docs/appendix/glossary#webhook "HTTPS POST callback from Koard when account or payment events occur.") configuration # Setting up Webhooks Learn how to configure and use webhooks to receive real-time updates from Koard's payment platform. [Get started with Koard](/docs/introduction) Koard allows you to add URLs that receive POST requests when specific events occur in your payment system. Each endpoint can be configured to receive a specific set of events, enabling real-time integration with your applications. **What you learn** In this guide, you'll learn: * How to set up webhook endpoints in Koard * How to configure event subscriptions and filters * How to test and verify webhook functionality * How to monitor webhook delivery and troubleshoot issues * Best practices for webhook security and reliability ## Before you begin This guide covers webhook configuration and management in Koard. For a better understanding of available events, see our [Available Events guide](/docs/webhooks/available-events). If you're ready to start processing payments, see our [Running Payments guide](/docs/setting-up-the-ios-sdk/running-payments). ## What are Webhooks? [Webhooks](/docs/appendix/glossary#webhook "HTTPS POST callback from Koard when account or payment events occur.") are HTTP callbacks that Koard sends to your application when specific events occur. This allows you to receive real-time notifications about payment events, transaction updates, and other important changes without constantly polling our APIs. **Key benefits:** * **Real-time Updates**: Get instant notifications about payment events * **Automated Processing**: Trigger automated workflows based on events * **Reduced Polling**: Eliminate the need to constantly poll for updates * **Better User Experience**: Provide immediate feedback to users ## Setting Up Webhook Endpoints ### Access the Developer Portal To set up a new webhook endpoint, navigate to one of the following URLs based on your environment: | Environment | URL | | ------------------------------------------------------------------------------------------------------------ | ------------------------------------- | | **[UAT](/docs/appendix/glossary#uat "Isolated sandbox environment — transactions do not move live money.")** | | | **Production** | | ![webhooks-1](/webhooks-1.png) _Koard Developer Portal - Webhook Configuration_ ### Create a New Endpoint 1. **Navigate to Add Endpoint**: Click "Add Endpoint" on the right side of the developer portal page 2. **Configure HTTPS Endpoint**: Enter your HTTPS endpoint URL that will receive POST requests 3. **Select Events**: Choose which events you want to subscribe to 4. **Save Configuration**: Complete the setup process ![webhooks-2](/webhooks-2.png) _Adding a new webhook endpoint in the Koard Developer Portal_ **Important**: Currently, webhooks can only be managed via the Portal, but API management is on the roadmap for creating and managing endpoints and event filters. ### Webhook Endpoint Requirements Your webhook endpoint must meet these requirements: * **HTTPS Only**: All webhook endpoints must use HTTPS * **POST Method**: Endpoints must accept POST requests * **JSON Payload**: Events are sent as JSON in the request body * **Quick Response**: Return a 2xx status code quickly (within 15 seconds; preferably acknowledge immediately and queue processing) ## Webhook Configuration ### Event Selection Choose which events you want to receive based on your integration needs. If you don't specify any event types, by default your endpoint will receive all events, regardless of type. This can be helpful for getting started and testing, but we recommend selecting specific events for production. | Event Category | Description | Common Events | | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Transaction Events** | Transaction lifecycle events | `transaction.create`, `transaction.authorize`, `transaction.sale`, `transaction.capture`, `transaction.increment`, `transaction.tip_adjust`, `transaction.reverse`, `transaction.cancel`, `transaction.refund` | | **Account Events** | Account management events | `account.created`, `account.updated`, `account.blocked`, `account.unblocked`, `account.deleted` | | **Terminal Events** | Terminal configuration events | `terminal.created`, `terminal.updated`, `terminal.blocked`, `terminal.unblocked`, `terminal.deleted` | | **Location Events** | Location management events | `location.created`, `location.updated`, `location.blocked`, `location.unblocked`, `location.deleted` | | **Batch Events** | Batch processing and settlement | `batch.opened`, `batch.submitted`, `batch.accepted`, `batch.partially_accepted`, `batch.rejected`, `batch.edited` | | **[API Key](/docs/appendix/glossary#api-key "Secret header credential for REST calls; v5 keys use scoped permissions.") Events** | API key management events | `api_key.created`, `api_key.revoked`, `api_key.reinstated`, `api_key.deleted` | | **Credential Events** | Merchant credential management events | `credential.created`, `credential.blocked`, `credential.unblocked`, `credential.deleted` | For a complete list of available events with schemas and examples, see our [Available Events guide](/docs/webhooks/available-events). ### Webhook Headers Koard uses industry-standard webhook headers powered by Svix for maximum compatibility: ```plaintext Content-Type: application/json svix-id: msg_p5jXN8AQM9LWM0D4loKWxJek svix-timestamp: 1614265330 svix-signature: v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE= User-Agent: Svix-Webhooks/1.0 ``` **Header Descriptions:** * `svix-id`: Unique webhook delivery identifier used for idempotent handling of retried deliveries * `svix-timestamp`: Unix timestamp when the webhook was sent (for replay attack prevention) * `svix-signature`: HMAC signature for verifying webhook authenticity * `Content-Type`: Always `application/json` ### Retry Policy Koard implements a robust retry policy with exponential backoff for failed webhook deliveries: | Retry Attempt | Delay | Time from First Attempt | | ------------- | ---------- | ----------------------- | | 1st | Immediate | 0 seconds | | 2nd | 5 seconds | 5 seconds | | 3rd | 5 minutes | \~5 minutes | | 4th | 30 minutes | \~35 minutes | | 5th | 2 hours | \~2 hours 35 minutes | | 6th | 5 hours | \~7 hours 35 minutes | | 7th | 10 hours | \~17 hours 35 minutes | | 8th | 10 hours | \~27 hours 35 minutes | **Key Points:** * **Response Timeout**: 15 seconds per attempt * **Success Criteria**: 2xx status code (200-299) indicates success * **Failure Criteria**: Any other status code or timeout triggers a retry * **Endpoint Disabling**: After 5 days of consecutive failures, endpoints are automatically disabled * **Manual Recovery**: Use the dashboard to recover or resend failed messages **Note**: When responding to webhooks, return a 2xx status code quickly. Process complex workflows asynchronously to avoid timeouts. ## Testing Your Webhook ### Create a Test Endpoint To test your webhook submission, create a webhook that can accept all transaction events and use the Koard SDK to process some transactions. This example uses your application's durable queue adapter: `webhookQueue.enqueue()` must resolve only after persistence succeeds. Deduplicate using the Svix message ID, and have a separate worker call `processWebhook()` and retry failed jobs. The business handlers and queue adapter are application code. ```javascript // Express.js example webhook endpoint const express = require('express'); const { Webhook } = require('svix'); const { webhookQueue } = require('./webhook-queue'); const app = express(); // Use express.raw so req.body is the exact bytes Svix signed. Do NOT JSON-parse // and re-stringify the body — that can change the bytes and fail verification. app.post('/webhooks/koard', express.raw({ type: 'application/json' }), async (req, res) => { const payload = req.body; // raw Buffer, exactly as received const headers = { 'svix-id': req.headers['svix-id'], 'svix-timestamp': req.headers['svix-timestamp'], 'svix-signature': req.headers['svix-signature'] }; let verifiedPayload; try { // Verify webhook signature using Svix library const wh = new Webhook(process.env.KOARD_WEBHOOK_SECRET); verifiedPayload = wh.verify(payload, headers); } catch (err) { console.error('Webhook verification failed:', err); return res.status(400).send('Invalid signature'); } try { await webhookQueue.enqueue({ id: headers['svix-id'], payload: verifiedPayload }); return res.status(200).send('OK'); } catch (err) { console.error('Webhook persistence failed:', err); return res.status(503).send('Retry later'); } }); async function processWebhook(payload) { // The body IS the resource object (flat) — there is no { event, data } envelope, // and the event type is not in the body. Subscribe each endpoint to specific // event types (developer portal), and/or branch on payload fields. For a // transaction endpoint, route on transaction_type and read status. const txn = payload; switch (txn.transaction_type) { case 'sale': await handleSale(txn); break; case 'capture': await handleCapture(txn); break; // Add more: 'auth', 'refund', 'reverse', 'tip_adjust', 'incremental_auth' default: console.log('Unhandled transaction type:', txn.transaction_type); } } app.listen(3000, () => { console.log('Webhook endpoint listening on port 3000'); }); ``` ### Test with Koard SDK Use the Koard SDK to process transactions and trigger webhook events: ```swift import KoardSDK // After login, active location selection, linking, and reader preparation. // Persist this eventId with your order before submitting. let eventId = UUID().uuidString let response = try await KoardMerchantSDK.shared.sale( amount: 1000, breakdown: PaymentBreakdown(subtotal: 1000, taxAmount: 0, tipType: .fixed), currency: CurrencyCode(currencyCode: "USD", displayName: "US Dollar"), eventId: eventId ) print(response.transaction?.status as Any) ``` ![webhooks-4](/webhooks-4.png) _Testing webhook functionality with Koard SDK transactions_ ## Webhook Security ### Signature Verification **Why Verify Webhooks?** Webhook signatures let you verify that webhook messages are actually sent by Koard and not a malicious actor. This prevents: * **Spoofing Attacks**: Malicious actors sending fake webhooks * **Replay Attacks**: Old webhooks being resent * **Man-in-the-Middle Attacks**: Webhooks being intercepted and modified For a detailed explanation, see [why you should verify webhooks](https://docs.svix.com/receiving/verifying-payloads/why). ### Using Svix Libraries (Recommended) Koard uses [Svix](https://www.svix.com/) for webhook delivery, which provides official libraries for easy verification: ```javascript Node.js const { Webhook } = require("svix"); const secret = process.env.KOARD_WEBHOOK_SECRET; // Get from Koard dashboard // Use express.raw so req.body is the exact bytes Svix signed. Do NOT JSON-parse // and re-stringify the body — that can change the bytes and fail verification. app.post('/webhooks/koard', express.raw({ type: 'application/json' }), (req, res) => { const payload = req.body; // raw Buffer, exactly as received const headers = { 'svix-id': req.headers['svix-id'], 'svix-timestamp': req.headers['svix-timestamp'], 'svix-signature': req.headers['svix-signature'] }; try { const wh = new Webhook(secret); const verifiedPayload = wh.verify(payload, headers); // Webhook is verified - process it processWebhook(verifiedPayload); res.status(200).json({ success: true }); } catch (err) { console.error('Webhook verification failed:', err.message); res.status(400).json({ error: 'Invalid signature' }); } }); ``` ```python Python from svix.webhooks import Webhook import os webhook_secret = os.environ['KOARD_WEBHOOK_SECRET'] @app.route('/webhooks/koard', methods=['POST']) def webhook_handler(): payload = request.get_data() headers = { 'svix-id': request.headers.get('svix-id'), 'svix-timestamp': request.headers.get('svix-timestamp'), 'svix-signature': request.headers.get('svix-signature') } try: wh = Webhook(webhook_secret) msg = wh.verify(payload, headers) # Webhook is verified - process it process_webhook(msg) return jsonify({'success': True}), 200 except Exception as e: print(f'Webhook verification failed: {e}') return jsonify({'error': 'Invalid signature'}), 400 ``` ```go Go import ( "encoding/json" svix "github.com/svix/svix-webhooks/go" ) func webhookHandler(w http.ResponseWriter, r *http.Request) { webhookSecret := os.Getenv("KOARD_WEBHOOK_SECRET") payload, _ := ioutil.ReadAll(r.Body) headers := http.Header{} headers.Set("svix-id", r.Header.Get("svix-id")) headers.Set("svix-timestamp", r.Header.Get("svix-timestamp")) headers.Set("svix-signature", r.Header.Get("svix-signature")) wh, _ := svix.NewWebhook(webhookSecret) err := wh.Verify(payload, headers) if err != nil { w.WriteHeader(http.StatusBadRequest) return } // Verified — decode the flat body before routing on its fields var txn map[string]interface{} if err := json.Unmarshal(payload, &txn); err != nil { w.WriteHeader(http.StatusBadRequest) return } processWebhook(txn) w.WriteHeader(http.StatusOK) } ``` For more examples in other languages (Ruby, PHP, Java, Rust, Kotlin, C#), see the [Svix webhook verification documentation](https://docs.svix.com/receiving/verifying-payloads/how). ### Manual Verification (Advanced) If you prefer to verify signatures manually without using the Svix library: ```javascript const crypto = require('crypto'); function verifyWebhookSignature(payload, headers, secret) { const timestamp = headers['svix-timestamp']; const signature = headers['svix-signature']; const msgId = headers['svix-id']; // Check timestamp to prevent replay attacks (optional but recommended) const timestampSeconds = parseInt(timestamp); const now = Math.floor(Date.now() / 1000); if (Math.abs(now - timestampSeconds) > 300) { // 5 minute tolerance throw new Error('Webhook timestamp too old'); } // Create the signed content const signedContent = ${msgId}.${timestamp}.${payload}; // Compute expected signature const expectedSignature = crypto .createHmac('sha256', secret) .update(signedContent, 'utf8') .digest('base64'); // Extract signature from header (format: "v1,signature") const signatureParts = signature.split(','); const headerSignature = signatureParts[1]; // Compare signatures return crypto.timingSafeEqual( Buffer.from(headerSignature), Buffer.from(expectedSignature) ); } ``` **Important**: Use the **raw payload body** for verification, not the parsed JSON. Different JSON parsers may produce different string representations. ### HTTPS Requirements * **HTTPS Only**: Webhook endpoints must use HTTPS * **Valid SSL Certificates**: SSL certificates must be valid and trusted * **No Self-Signed Certificates**: Self-signed certificates are not allowed * **TLS 1.2+**: Minimum TLS version 1.2 required ### Security Best Practices * **Verify Signatures**: Always verify webhook signatures * **Use HTTPS**: Only accept webhooks over HTTPS * **Validate Payloads**: Validate webhook payloads before processing * **Rate Limiting**: Implement rate limiting to prevent abuse * **Logging**: Log all webhook events for debugging and security ## Monitoring and Logging ### Webhook Logs All webhooks come with detailed logging around deliverability, attempts, and activity history: 1. **Access Logs**: Go to the Logs tab in the developer portal 2. **Filter Events**: Filter by event type and message content 3. **View Details**: Click on individual events to see delivery details 4. **Monitor Status**: Track delivery status and retry attempts ![webhooks-3](/webhooks-3.png) _Webhook logs and delivery monitoring in the Koard Developer Portal_ ### Delivery Status Monitor webhook delivery status: | Status | Description | | ------------- | --------------------------------------------------- | | **Delivered** | Webhook successfully delivered and acknowledged | | **Pending** | Webhook delivery in progress or scheduled for retry | | **Failed** | Webhook delivery failed after all retry attempts | ### Troubleshooting Common webhook issues and solutions: **Not Using the Raw Payload Body** This is the most common issue. When generating the signed content, Koard uses the raw string body of the message payload. If you convert JSON payloads into strings using methods like `JSON.stringify()`, different implementations may produce different string representations, leading to verification failures. **Solution:** Use the raw request body exactly as received. In Express.js: use `express.raw()` or access `req.body` before JSON parsing. **Missing or Wrong Secret Key** Using an incorrect or outdated webhook secret will cause all verifications to fail. **Solution:** Get your webhook secret from the Koard Developer Portal. Remember that secrets are unique to each endpoint. **Timestamp Too Old** Webhooks with timestamps older than 5 minutes are rejected to prevent replay attacks. **Solution:** Ensure your server's system time is synchronized (use NTP). **Sending Wrong Response Codes** When Koard receives a 2xx status code (200-299), it's interpreted as successful delivery, even if your response payload indicates a failure. **Solution:** Return appropriate status codes: * `200` for successful processing * `400-499` for client errors (will not retry) * `500-599` for server errors (will retry) **Response Timeouts** Webhooks that don't respond within 15 seconds are considered failed and will be retried. **Solution:** Respond immediately with `200 OK` and process webhooks asynchronously: ```javascript app.post('/webhooks/koard', async (req, res) => { // Verify signature const wh = new Webhook(secret); const payload = wh.verify(req.body, req.headers); // Respond immediately res.status(200).send('OK'); // Process asynchronously queue.add('process-webhook', payload); }); ``` ### Failure Recovery **Re-enable a Disabled Endpoint** If all attempts to a specific endpoint fail for 5 days, the endpoint will be automatically disabled. **To re-enable:** 1. Go to the Koard Developer Portal 2. Navigate to Webhooks 3. Find the disabled endpoint 4. Click "Enable Endpoint" **Recovering Failed Messages** **Single Message Recovery:** 1. Find the message in the Developer Portal 2. Click the options menu next to the attempt 3. Click "Resend" to retry delivery **Bulk Message Recovery:** 1. Go to the endpoint details page 2. Click "Options" → "Recover Failed Messages" 3. Choose a time window to recover from 4. All failed messages in that window will be resent **Recovery from Specific Timestamp:** 1. Find any message near your desired recovery point 2. Click the options menu on that message 3. Select "Replay all failed messages since this time" ## Best Practices ### Endpoint Design * **Quick Response**: Return 2xx status codes quickly (within 15 seconds) * **Persist, Respond, Process**: Verify the signature, persist the event and its job durably, then acknowledge and process asynchronously * **Disable CSRF Protection**: Disable CSRF checks for webhook endpoints * **Use Raw Body**: Access raw request body for signature verification * **Error Handling**: Implement proper error handling and logging ### Event Processing - Idempotency **Why Idempotency Matters:** Webhooks may be delivered more than once due to network issues, retries, or recovery operations. Your endpoint must handle duplicate events gracefully. **Implementation:** Use the `svix-id` header as the unique delivery ID in the [durable queue example](#create-a-test-endpoint). That example returns `200` only after `webhookQueue.enqueue()` succeeds and `503` when persistence fails. The queue adapter must atomically persist the delivery ID, verified payload, and worker job. A unique constraint on the delivery ID prevents concurrent retries from creating duplicate jobs. An already-persisted delivery can be acknowledged because its job is still queued, being retried, or completed. Keep **delivery persistence** separate from **business completion**. Workers mark a job completed only after business processing succeeds, retain failed jobs for retry, and recover interrupted jobs after a restart. Do not record an event as processed before calling the business handler, or return success while an unpersisted handler runs in the background; either pattern can lose a failed event permanently. **Best Practices:** * Verify the signature over the raw request body before accepting the delivery. * Await durable persistence before returning `200`; return a non-2xx response if persistence fails. * Deduplicate atomically in durable storage, rather than with an in-memory `Set` or a separate read-then-insert. * Make business handlers idempotent too: worker retries can repeat a side effect after an uncertain outcome. * Retain delivery IDs for your replay window and keep pending or failed jobs until they are resolved. ### Event Ordering **Important:** Delivery is best-effort ordered — events may arrive out of sequence due to network conditions, retries, or processing delays. **Order by the payload's `created_at`.** Koard is event-sourced: a transaction's lifecycle (`authorize` → `incremental_auth` → `capture` → `refund` → …) is a series of events that **share one `transaction_id`**, each with its own `event_id` and its own `created_at`. That `created_at` is assigned as `max(now, previous_event + 1)` in **milliseconds**, so within a `transaction_id` it is **strictly increasing** — a reliable per-event ordering key. Don't use the `svix-timestamp` header for ordering — it's the delivery-_attempt_ time and changes on retries. **Persist atomically** so out-of-order and concurrent deliveries can't regress state: upsert and only advance when the incoming `created_at` is greater than the stored watermark (a single statement, no read-then-write race). Deduplicate exact retries on `svix-id` (see the Idempotency section above). ```javascript app.post('/webhooks/koard', express.raw({ type: 'application/json' }), async (req, res) => { const wh = new Webhook(secret); const txn = wh.verify(req.body, { 'svix-id': req.headers['svix-id'], 'svix-timestamp': req.headers['svix-timestamp'], 'svix-signature': req.headers['svix-signature'], }); // created_at is a per-event, strictly increasing (ms) watermark for a // transaction_id. Upsert atomically and only advance when this event is // newer, so out-of-order or concurrent deliveries can't regress the state. await db.query( INSERT INTO transactions (transaction_id, status, payload, last_created_at) VALUES ($1, $2, $3, $4) ON CONFLICT (transaction_id) DO UPDATE SET status = EXCLUDED.status, payload = EXCLUDED.payload, last_created_at = EXCLUDED.last_created_at WHERE EXCLUDED.last_created_at > transactions.last_created_at, [txn.transaction_id, txn.status, txn, txn.created_at], ); res.status(200).send('OK'); }); ``` If you need strict, guaranteed ordering, use [Svix FIFO endpoints](https://docs.svix.com/advanced-endpoints/fifo-endpoints). Alternatively, treat the webhook as a signal and re-fetch the authoritative transaction from the Koard API before acting. ### Security * **Always Verify Signatures**: Never skip signature verification in production * **Use Svix Libraries**: Use official Svix libraries for proper verification * **HTTPS Only**: Use HTTPS for all webhook endpoints (required by Koard) * **Validate Timestamps**: Reject webhooks with old timestamps (>5 minutes) * **Secret Management**: Securely store webhook secrets (use environment variables) * **Rotate Secrets**: Periodically rotate webhook secrets * **Monitor Failures**: Alert on repeated verification failures (potential attack) ### Monitoring and Alerting * **Track Delivery**: Monitor webhook delivery success rates * **Set Up Alerts**: Alert on repeated failures or timeouts * **Log Everything**: Log all webhook events for debugging * **Monitor Processing Time**: Ensure webhooks process within timeout * **Dashboard Review**: Regularly review webhook logs in Koard Developer Portal ## See also This wraps up the webhook setup guide. See the links below for related information: * [Available Events](/docs/webhooks/available-events) - Complete list of webhook events * [Running Payments](/docs/setting-up-the-ios-sdk/running-payments) - Payment processing fundamentals * [Batch and Settlements](/docs/batch-and-settlements/overview) - Batch processing overview * [Getting Started](/docs/introduction) - Koard platform introduction # Available Events Complete list of webhook events Koard can deliver, with the **exact payload your endpoint receives**. ## Payload shape Almost every webhook request body is the **resource object itself, as flat JSON** — for example a `batch.accepted` body is a Batch, a `transaction.sale` body is a Transaction. There is **no `{"event": ..., "data": ...}` envelope**; the fields shown below are top-level. The exception is the **`*.deleted` events** (`account.deleted`, `terminal.deleted`, `location.deleted`), which carry a sparse **id-only** payload — just the resource id (and `account_id` where applicable), not the full object, since the resource no longer exists. Each event's exact body is shown below. **The event type is not in the request body.** Koard delivers webhooks through Svix, so the body is the raw payload and the delivery metadata rides in headers (`svix-id`, `svix-timestamp`, `svix-signature`). To know which event an endpoint received, **subscribe that endpoint to specific event types** in the developer portal (one endpoint per event or per category). Transactions and batches also carry discriminators in the body — branch on `transaction_type` / `status` for transactions and on `status` for batches. Conventions used in every payload: * **Money is in minor units** (cents) — `5000` means $50.00. * **Timestamps:** transaction `created_at` is a Unix timestamp in **milliseconds**; every other resource (`batch`, `account`, `terminal`, `location`, `api_key`, `credential`) uses **ISO-8601** strings (`opened_at`, `created_at`, …). * **Secrets are masked:** the card is truncated (`4113********4242`), an API key is `"****" + last4`, and a credential PIN is `"****"`. Full secrets and hash material are never sent. * Unset optional fields are present with a `null` value. ## Surcharge cancellation For Fiserv Nashville Global, rejecting a pending surcharge with `confirm(..., false)` produces `status: canceled` and `status_reason: surcharge_declined_by_payer`. Attempting to accept after the 120-second context expires produces `status_reason: surcharge_expired`. This flow has not authorized the pending sale, so these outcomes are cancellations, not reversals of an existing hold. Both confirmation cancellation and background expiry cleanup emit **`transaction.cancel`** with `status: canceled`. Inspect `status_reason` to distinguish rejection from expiry, and use `event_id` to deduplicate deliveries. For refunds, `tax_amount` and `surcharge_amount` on a refund event describe that refund's components. See [refund breakdowns](/docs/payments/methods/refund#fiserv-nashville-global-refund-breakdown). ## Transaction outcomes Payment attempt webhooks are named after the **operation attempted**: a sale emits `transaction.sale` and an authorization emits `transaction.authorize`, whether approved, declined or errored. Pending payments emit `transaction.create`; cancellations emit `transaction.cancel`. The **outcome** lives in the payload: * **`status`** — the resulting state (e.g. `authorized`, `captured`, `declined`, `error`). * **`status_reason`** — a machine-readable reason for that status (e.g. `approved`, `insufficient_funds`, `payment_failed`). So your handler should **branch on `status`** to tell success from failure. **Failed and declined attempts fire the same event as a success.** A declined or errored **auth** is still delivered as `transaction.authorize`; a failed **sale** as `transaction.sale`. There is no separate `transaction.failed` or `transaction.declined` event — read `status` / `status_reason` to handle the result. ### Transaction statuses `status` reflects the resulting state of the transaction: | `status` | Meaning | Outcome | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------- | | `authorized` | Funds held, not yet captured | ✅ Success | | `captured` | Funds captured (sale or capture) | ✅ Success | | `settled` | Funds finalized in a settled batch | ✅ Success | | `refunded` | Refund completed | ✅ Success | | `reversed` | Authorization reversed / voided | ✅ Success | | `pending` | Awaiting external input | ⏳ In progress | | `surcharge_pending` | Awaiting [surcharge](/docs/appendix/glossary#surcharge "Fee on eligible credit-card transactions to offset processing costs (not debit).") confirmation | ⏳ In progress | | `declined` | Declined by the issuer | ❌ Failure | | `error` | The attempt could not be completed (host / format / network error) | ❌ Failure | | `canceled` | Canceled by the user or system | ⛔ Not completed | ### Status reasons `status_reason` explains **why** a transaction ended in its `status`. Common values: | `status_reason` | Meaning | | --------------------------------------------------- | ------------------------------------------------------------------------- | | `approved` | The operation was approved | | `declined` | The issuer declined the transaction | | `insufficient_funds` | Declined — the account had insufficient funds | | `invalid_card` | The card was invalid or expired | | `payment_failed` | The host / gateway returned an unclassified failure (not a clean decline) | | `processor_timeout` / `timed_out` | No response from the processor in time | | `network_connectivity_error` | Could not reach the processor | | `invalid_gateway_response` / `format_error` | The processor response could not be parsed | | `invalid_terminal` | The terminal / BIN is not boarded or is misconfigured | | `partial_approval` | Approved for less than the requested amount | | `incremental_auth_declined` | An incremental authorization was declined | | `invalid_tip_adjustment` | A tip adjustment is not allowed for this transaction | | `exceeds_original_auth` | A capture / adjustment exceeds the original authorization | | `invalid_capture_sequence` | A capture was attempted in an invalid state | | `surcharge_declined_by_payer` / `surcharge_expired` | The cardholder declined the surcharge, or the prompt expired | | `user_canceled` / `merchant_canceled` | Canceled by the cardholder or the merchant | | `duplicate_event` | A duplicate event ID was received | ### Parsing a transaction webhook Verify the Svix signature first (see [Setting up Webhooks](/docs/webhooks/setting-up-webhooks)), then read the **flat** transaction body. This endpoint is subscribed to transaction events, so the body is a Transaction; route on `transaction_type` and branch on `status`. ```python Python # POST https://your-app.com/webhooks/koard (subscribed to transaction.* events) from flask import request, jsonify SUCCESS_STATUSES = {"authorized", "captured", "settled", "refunded", "reversed"} IN_PROGRESS_STATUSES = {"pending", "surcharge_pending"} @app.route("/webhooks/koard", methods=["POST"]) def koard_webhook(): txn = request.get_json() # the body IS the transaction (flat, no envelope) operation = txn["transaction_type"] # sale / auth / capture / refund / reverse / ... status = txn.get("status") if status in SUCCESS_STATUSES: handle_success(operation, txn) elif status in IN_PROGRESS_STATUSES: handle_in_progress(operation, status, txn) # awaiting input; not final yet elif status == "canceled": handle_canceled(operation, txn) # not completed else: # only declined / error land here — status_reason explains why handle_failure(operation, status, txn.get("status_reason"), txn) return jsonify(received=True), 200 ``` ```javascript Node.js // POST https://your-app.com/webhooks/koard (subscribed to transaction.* events) const SUCCESS_STATUSES = new Set([ "authorized", "captured", "settled", "refunded", "reversed", ]); const IN_PROGRESS_STATUSES = new Set(["pending", "surcharge_pending"]); app.post("/webhooks/koard", (req, res) => { const txn = req.body; // the body IS the transaction (flat, no envelope) const { transaction_type: operation, status, status_reason } = txn; if (SUCCESS_STATUSES.has(status)) { handleSuccess(operation, txn); } else if (IN_PROGRESS_STATUSES.has(status)) { handleInProgress(operation, status, txn); // awaiting input; not final yet } else if (status === "canceled") { handleCanceled(operation, txn); // not completed } else { // only declined / error land here — `status_reason` explains why handleFailure(operation, status, status_reason, txn); } res.status(200).json({ received: true }); }); ``` ```ruby Ruby # POST https://your-app.com/webhooks/koard (subscribed to transaction.* events) SUCCESS_STATUSES = %w[authorized captured settled refunded reversed].freeze IN_PROGRESS_STATUSES = %w[pending surcharge_pending].freeze post "/webhooks/koard" do txn = JSON.parse(request.body.read) # the body IS the transaction (flat) operation = txn["transaction_type"] status = txn["status"] if SUCCESS_STATUSES.include?(status) handle_success(operation, txn) elsif IN_PROGRESS_STATUSES.include?(status) handle_in_progress(operation, status, txn) # awaiting input; not final yet elsif status == "canceled" handle_canceled(operation, txn) # not completed else # only declined / error land here handle_failure(operation, status, txn["status_reason"], txn) end status 200 { received: true }.to_json end ``` ```java Java // POST https://your-app.com/webhooks/koard (subscribed to transaction.* events) private static final Set SUCCESS_STATUSES = Set.of("authorized", "captured", "settled", "refunded", "reversed"); private static final Set IN_PROGRESS_STATUSES = Set.of("pending", "surcharge_pending"); @PostMapping("/webhooks/koard") public ResponseEntity koardWebhook(@RequestBody Map txn) { String operation = (String) txn.get("transaction_type"); String status = (String) txn.get("status"); if (SUCCESS_STATUSES.contains(status)) { handleSuccess(operation, txn); } else if (IN_PROGRESS_STATUSES.contains(status)) { handleInProgress(operation, status, txn); // awaiting input; not final yet } else if ("canceled".equals(status)) { handleCanceled(operation, txn); // not completed } else { // only declined / error land here handleFailure(operation, status, (String) txn.get("status_reason"), txn); } return ResponseEntity.ok().build(); } ``` ```php PHP true]); ``` The examples below show the exact body delivered for each event. ## Transaction Events ### transaction.authorize Card authorized — funds held, not yet captured. ```json { "transaction_id": "6f6b6d2e-1c3a-4a9e-9d21-2f0e7b8c9a10", "event_id": "6F6B6D2E-1C3A-4A9E-9D21-2F0E7B8C9A10", "mid": "445566778899", "tid": "TERM0001", "processor_mid": "5289003", "processor_tid": "5289003", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv", "device_id": "dev_5Hj3Kl8Mn2Pq6Rt", "processor": "tsys", "gateway": "sierra", "currency": "USD", "location_id": "loc_7Bd4Cf9Gh2Jk5Lm", "gateway_transaction_id": "BG6RJZHK8Q", "subtotal": 4200, "tip_amount": 800, "tip_type": "amount", "tip_capture_mode": null, "tax_amount": 0, "tax_rate": 0, "tax_source": null, "tax_basis": null, "total_amount": 5000, "surcharge_applied": false, "surcharge_amount": 0, "surcharge_rate": 0, "surcharge_source": null, "surcharge_basis": null, "surcharge_confirmation_required": null, "refunded": 0, "reversed": 0, "created_at": 1768470600000, "status": "authorized", "status_reason": "approved", "payment_method": "contactlessIcc", "card_type": "credit", "card_brand": "visa", "card": "4113********4242", "additional_details": {}, "gateway_transaction_response": { "responseCode": "A", "responseMessage": "APPROVAL", "approvalCode": "522841", "processorResponseCode": "00" }, "processor_response_code": "00", "processor_response_message": "Approved", "transaction_type": "auth", "apple_transaction_id": "", "reader_identifier": "rdr_8Wq2Er5Ty7Ui", "owner_id": "acct_2Nk9Lm4Pq7Rs1Tv", "parent_account_ids": [ "acct_0Pp1Qq2Rr3Ss4Tt" ], "merchant_name": "Blue Bottle Coffee", "psp": null, "batch_id": "batch_6Yh3Uj8Ik2Ol", "device_type": null, "terminal_details": null, "history": [] } ``` ### transaction.sale Sale — card authorized and captured in one step. ```json { "transaction_id": "6f6b6d2e-1c3a-4a9e-9d21-2f0e7b8c9a10", "event_id": "6F6B6D2E-1C3A-4A9E-9D21-2F0E7B8C9A10", "mid": "445566778899", "tid": "TERM0001", "processor_mid": "5289003", "processor_tid": "5289003", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv", "device_id": "dev_5Hj3Kl8Mn2Pq6Rt", "processor": "tsys", "gateway": "sierra", "currency": "USD", "location_id": "loc_7Bd4Cf9Gh2Jk5Lm", "gateway_transaction_id": "BG6RJZHK8Q", "subtotal": 4200, "tip_amount": 800, "tip_type": "amount", "tip_capture_mode": null, "tax_amount": 0, "tax_rate": 0, "tax_source": null, "tax_basis": null, "total_amount": 5000, "surcharge_applied": false, "surcharge_amount": 0, "surcharge_rate": 0, "surcharge_source": null, "surcharge_basis": null, "surcharge_confirmation_required": null, "refunded": 0, "reversed": 0, "created_at": 1768470600000, "status": "captured", "status_reason": "approved", "payment_method": "contactlessIcc", "card_type": "credit", "card_brand": "visa", "card": "4113********4242", "additional_details": {}, "gateway_transaction_response": { "responseCode": "A", "responseMessage": "APPROVAL", "approvalCode": "522841", "processorResponseCode": "00" }, "processor_response_code": "00", "processor_response_message": "Approved", "transaction_type": "sale", "apple_transaction_id": "", "reader_identifier": "rdr_8Wq2Er5Ty7Ui", "owner_id": "acct_2Nk9Lm4Pq7Rs1Tv", "parent_account_ids": [ "acct_0Pp1Qq2Rr3Ss4Tt" ], "merchant_name": "Blue Bottle Coffee", "psp": null, "batch_id": "batch_6Yh3Uj8Ik2Ol", "device_type": null, "terminal_details": null, "history": [] } ``` ### transaction.capture A prior authorization was captured for settlement. ```json { "transaction_id": "6f6b6d2e-1c3a-4a9e-9d21-2f0e7b8c9a10", "event_id": "6F6B6D2E-1C3A-4A9E-9D21-2F0E7B8C9A10", "mid": "445566778899", "tid": "TERM0001", "processor_mid": "5289003", "processor_tid": "5289003", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv", "device_id": "dev_5Hj3Kl8Mn2Pq6Rt", "processor": "tsys", "gateway": "sierra", "currency": "USD", "location_id": "loc_7Bd4Cf9Gh2Jk5Lm", "gateway_transaction_id": "BG6RJZHK8Q", "subtotal": 4200, "tip_amount": 800, "tip_type": "amount", "tip_capture_mode": null, "tax_amount": 0, "tax_rate": 0, "tax_source": null, "tax_basis": null, "total_amount": 5000, "surcharge_applied": false, "surcharge_amount": 0, "surcharge_rate": 0, "surcharge_source": null, "surcharge_basis": null, "surcharge_confirmation_required": null, "refunded": 0, "reversed": 0, "created_at": 1768470600000, "status": "captured", "status_reason": "approved", "payment_method": "contactlessIcc", "card_type": "credit", "card_brand": "visa", "card": "4113********4242", "additional_details": {}, "gateway_transaction_response": { "responseCode": "A", "responseMessage": "APPROVAL", "approvalCode": "522841", "processorResponseCode": "00" }, "processor_response_code": "00", "processor_response_message": "Approved", "transaction_type": "capture", "apple_transaction_id": "", "reader_identifier": "rdr_8Wq2Er5Ty7Ui", "owner_id": "acct_2Nk9Lm4Pq7Rs1Tv", "parent_account_ids": [ "acct_0Pp1Qq2Rr3Ss4Tt" ], "merchant_name": "Blue Bottle Coffee", "psp": null, "batch_id": "batch_6Yh3Uj8Ik2Ol", "device_type": null, "terminal_details": null, "history": [] } ``` ### transaction.cancel Transaction cancelled before settlement. ```json { "transaction_id": "6f6b6d2e-1c3a-4a9e-9d21-2f0e7b8c9a10", "event_id": "6F6B6D2E-1C3A-4A9E-9D21-2F0E7B8C9A10", "mid": "445566778899", "tid": "TERM0001", "processor_mid": "5289003", "processor_tid": "5289003", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv", "device_id": "dev_5Hj3Kl8Mn2Pq6Rt", "processor": "tsys", "gateway": "sierra", "currency": "USD", "location_id": "loc_7Bd4Cf9Gh2Jk5Lm", "gateway_transaction_id": "BG6RJZHK8Q", "subtotal": 4200, "tip_amount": 800, "tip_type": "amount", "tip_capture_mode": null, "tax_amount": 0, "tax_rate": 0, "tax_source": null, "tax_basis": null, "total_amount": 5000, "surcharge_applied": false, "surcharge_amount": 0, "surcharge_rate": 0, "surcharge_source": null, "surcharge_basis": null, "surcharge_confirmation_required": null, "refunded": 0, "reversed": 0, "created_at": 1768470600000, "status": "canceled", "status_reason": "user_canceled", "payment_method": "contactlessIcc", "card_type": "credit", "card_brand": "visa", "card": "4113********4242", "additional_details": {}, "gateway_transaction_response": { "responseCode": "A", "responseMessage": "APPROVAL", "approvalCode": "522841", "processorResponseCode": "00" }, "processor_response_code": "00", "processor_response_message": "Approved", "transaction_type": "sale", "apple_transaction_id": "", "reader_identifier": "rdr_8Wq2Er5Ty7Ui", "owner_id": "acct_2Nk9Lm4Pq7Rs1Tv", "parent_account_ids": [ "acct_0Pp1Qq2Rr3Ss4Tt" ], "merchant_name": "Blue Bottle Coffee", "psp": null, "batch_id": "batch_6Yh3Uj8Ik2Ol", "device_type": null, "terminal_details": null, "history": [] } ``` ### transaction.create A transaction record was created. ```json { "transaction_id": "6f6b6d2e-1c3a-4a9e-9d21-2f0e7b8c9a10", "event_id": "6F6B6D2E-1C3A-4A9E-9D21-2F0E7B8C9A10", "mid": "445566778899", "tid": "TERM0001", "processor_mid": "5289003", "processor_tid": "5289003", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv", "device_id": "dev_5Hj3Kl8Mn2Pq6Rt", "processor": "tsys", "gateway": "sierra", "currency": "USD", "location_id": "loc_7Bd4Cf9Gh2Jk5Lm", "gateway_transaction_id": "BG6RJZHK8Q", "subtotal": 4200, "tip_amount": 800, "tip_type": "amount", "tip_capture_mode": null, "tax_amount": 0, "tax_rate": 0, "tax_source": null, "tax_basis": null, "total_amount": 5000, "surcharge_applied": false, "surcharge_amount": 0, "surcharge_rate": 0, "surcharge_source": null, "surcharge_basis": null, "surcharge_confirmation_required": null, "refunded": 0, "reversed": 0, "created_at": 1768470600000, "status": "pending", "status_reason": "pending", "payment_method": "contactlessIcc", "card_type": "credit", "card_brand": "visa", "card": "4113********4242", "additional_details": {}, "gateway_transaction_response": { "responseCode": "A", "responseMessage": "APPROVAL", "approvalCode": "522841", "processorResponseCode": "00" }, "processor_response_code": "00", "processor_response_message": "Approved", "transaction_type": "sale", "apple_transaction_id": "", "reader_identifier": "rdr_8Wq2Er5Ty7Ui", "owner_id": "acct_2Nk9Lm4Pq7Rs1Tv", "parent_account_ids": [ "acct_0Pp1Qq2Rr3Ss4Tt" ], "merchant_name": "Blue Bottle Coffee", "psp": null, "batch_id": null, "device_type": null, "terminal_details": null, "history": [] } ``` ### transaction.reverse Authorization voided/undone after auth, before settlement. ```json { "transaction_id": "6f6b6d2e-1c3a-4a9e-9d21-2f0e7b8c9a10", "event_id": "6F6B6D2E-1C3A-4A9E-9D21-2F0E7B8C9A10", "mid": "445566778899", "tid": "TERM0001", "processor_mid": "5289003", "processor_tid": "5289003", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv", "device_id": "dev_5Hj3Kl8Mn2Pq6Rt", "processor": "tsys", "gateway": "sierra", "currency": "USD", "location_id": "loc_7Bd4Cf9Gh2Jk5Lm", "gateway_transaction_id": "BG6RJZHK8Q", "subtotal": 4200, "tip_amount": 800, "tip_type": "amount", "tip_capture_mode": null, "tax_amount": 0, "tax_rate": 0, "tax_source": null, "tax_basis": null, "total_amount": 5000, "surcharge_applied": false, "surcharge_amount": 0, "surcharge_rate": 0, "surcharge_source": null, "surcharge_basis": null, "surcharge_confirmation_required": null, "refunded": 0, "reversed": 5000, "created_at": 1768470600000, "status": "reversed", "status_reason": "approved", "payment_method": "contactlessIcc", "card_type": "credit", "card_brand": "visa", "card": "4113********4242", "additional_details": {}, "gateway_transaction_response": { "responseCode": "A", "responseMessage": "APPROVAL", "approvalCode": "522841", "processorResponseCode": "00" }, "processor_response_code": "00", "processor_response_message": "Approved", "transaction_type": "reverse", "apple_transaction_id": "", "reader_identifier": "rdr_8Wq2Er5Ty7Ui", "owner_id": "acct_2Nk9Lm4Pq7Rs1Tv", "parent_account_ids": [ "acct_0Pp1Qq2Rr3Ss4Tt" ], "merchant_name": "Blue Bottle Coffee", "psp": null, "batch_id": "batch_6Yh3Uj8Ik2Ol", "device_type": null, "terminal_details": null, "history": [] } ``` ### transaction.refund Funds refunded to the cardholder after settlement. ```json { "transaction_id": "6f6b6d2e-1c3a-4a9e-9d21-2f0e7b8c9a10", "event_id": "6F6B6D2E-1C3A-4A9E-9D21-2F0E7B8C9A10", "mid": "445566778899", "tid": "TERM0001", "processor_mid": "5289003", "processor_tid": "5289003", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv", "device_id": "dev_5Hj3Kl8Mn2Pq6Rt", "processor": "tsys", "gateway": "sierra", "currency": "USD", "location_id": "loc_7Bd4Cf9Gh2Jk5Lm", "gateway_transaction_id": "BG6RJZHK8Q", "subtotal": 4200, "tip_amount": 800, "tip_type": "amount", "tip_capture_mode": null, "tax_amount": 0, "tax_rate": 0, "tax_source": null, "tax_basis": null, "total_amount": 5000, "surcharge_applied": false, "surcharge_amount": 0, "surcharge_rate": 0, "surcharge_source": null, "surcharge_basis": null, "surcharge_confirmation_required": null, "refunded": 5000, "reversed": 0, "created_at": 1768470600000, "status": "refunded", "status_reason": "approved", "payment_method": "contactlessIcc", "card_type": "credit", "card_brand": "visa", "card": "4113********4242", "additional_details": {}, "gateway_transaction_response": { "responseCode": "A", "responseMessage": "APPROVAL", "approvalCode": "522841", "processorResponseCode": "00" }, "processor_response_code": "00", "processor_response_message": "Approved", "transaction_type": "refund", "apple_transaction_id": "", "reader_identifier": "rdr_8Wq2Er5Ty7Ui", "owner_id": "acct_2Nk9Lm4Pq7Rs1Tv", "parent_account_ids": [ "acct_0Pp1Qq2Rr3Ss4Tt" ], "merchant_name": "Blue Bottle Coffee", "psp": null, "batch_id": "batch_6Yh3Uj8Ik2Ol", "device_type": null, "terminal_details": null, "history": [] } ``` ### transaction.increment An existing authorization amount was increased ([incremental auth](/docs/appendix/glossary#incremental-auth "Increase an existing authorization hold by a delta amount.")). ```json { "transaction_id": "6f6b6d2e-1c3a-4a9e-9d21-2f0e7b8c9a10", "event_id": "6F6B6D2E-1C3A-4A9E-9D21-2F0E7B8C9A10", "mid": "445566778899", "tid": "TERM0001", "processor_mid": "5289003", "processor_tid": "5289003", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv", "device_id": "dev_5Hj3Kl8Mn2Pq6Rt", "processor": "tsys", "gateway": "sierra", "currency": "USD", "location_id": "loc_7Bd4Cf9Gh2Jk5Lm", "gateway_transaction_id": "BG6RJZHK8Q", "subtotal": 6200, "tip_amount": 800, "tip_type": "amount", "tip_capture_mode": null, "tax_amount": 0, "tax_rate": 0, "tax_source": null, "tax_basis": null, "total_amount": 7000, "surcharge_applied": false, "surcharge_amount": 0, "surcharge_rate": 0, "surcharge_source": null, "surcharge_basis": null, "surcharge_confirmation_required": null, "refunded": 0, "reversed": 0, "created_at": 1768470600000, "status": "authorized", "status_reason": "approved", "payment_method": "contactlessIcc", "card_type": "credit", "card_brand": "visa", "card": "4113********4242", "additional_details": {}, "gateway_transaction_response": { "responseCode": "A", "responseMessage": "APPROVAL", "approvalCode": "522841", "processorResponseCode": "00" }, "processor_response_code": "00", "processor_response_message": "Approved", "transaction_type": "incremental_auth", "apple_transaction_id": "", "reader_identifier": "rdr_8Wq2Er5Ty7Ui", "owner_id": "acct_2Nk9Lm4Pq7Rs1Tv", "parent_account_ids": [ "acct_0Pp1Qq2Rr3Ss4Tt" ], "merchant_name": "Blue Bottle Coffee", "psp": null, "batch_id": "batch_6Yh3Uj8Ik2Ol", "device_type": null, "terminal_details": null, "history": [] } ``` ### transaction.tip\_adjust A tip was added or changed on a captured transaction. ```json { "transaction_id": "6f6b6d2e-1c3a-4a9e-9d21-2f0e7b8c9a10", "event_id": "6F6B6D2E-1C3A-4A9E-9D21-2F0E7B8C9A10", "mid": "445566778899", "tid": "TERM0001", "processor_mid": "5289003", "processor_tid": "5289003", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv", "device_id": "dev_5Hj3Kl8Mn2Pq6Rt", "processor": "tsys", "gateway": "sierra", "currency": "USD", "location_id": "loc_7Bd4Cf9Gh2Jk5Lm", "gateway_transaction_id": "BG6RJZHK8Q", "subtotal": 4200, "tip_amount": 1200, "tip_type": "amount", "tip_capture_mode": null, "tax_amount": 0, "tax_rate": 0, "tax_source": null, "tax_basis": null, "total_amount": 5400, "surcharge_applied": false, "surcharge_amount": 0, "surcharge_rate": 0, "surcharge_source": null, "surcharge_basis": null, "surcharge_confirmation_required": null, "refunded": 0, "reversed": 0, "created_at": 1768470600000, "status": "captured", "status_reason": "approved", "payment_method": "contactlessIcc", "card_type": "credit", "card_brand": "visa", "card": "4113********4242", "additional_details": {}, "gateway_transaction_response": { "responseCode": "A", "responseMessage": "APPROVAL", "approvalCode": "522841", "processorResponseCode": "00" }, "processor_response_code": "00", "processor_response_message": "Approved", "transaction_type": "tip_adjust", "apple_transaction_id": "", "reader_identifier": "rdr_8Wq2Er5Ty7Ui", "owner_id": "acct_2Nk9Lm4Pq7Rs1Tv", "parent_account_ids": [ "acct_0Pp1Qq2Rr3Ss4Tt" ], "merchant_name": "Blue Bottle Coffee", "psp": null, "batch_id": "batch_6Yh3Uj8Ik2Ol", "device_type": null, "terminal_details": null, "history": [] } ``` ## Batch Events ### batch.opened A settlement batch was opened. ```json { "id": "batch_6Yh3Uj8Ik2Ol", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv", "terminal_id": "term_5Hj3Kl8Mn2Pq6Rt", "processor_name": "tsys", "processor_config_id": "pcfg_4Df7Gh1Jk8Lm", "status": "open", "captured_amount": 0, "refunded_amount": 0, "transaction_count": 0, "processor_batch_id": null, "opened_at": "2026-01-15T08:00:00Z", "closed_at": null, "response": null, "batch_metadata": {} } ``` ### batch.rejected The processor rejected the batch. ```json { "id": "batch_6Yh3Uj8Ik2Ol", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv", "terminal_id": "term_5Hj3Kl8Mn2Pq6Rt", "processor_name": "tsys", "processor_config_id": "pcfg_4Df7Gh1Jk8Lm", "status": "rejected", "captured_amount": 128400, "refunded_amount": 5000, "transaction_count": 37, "processor_batch_id": "TSYS-BATCH-000481", "opened_at": "2026-01-15T08:00:00Z", "closed_at": null, "response": { "rejection_reason": "Host unavailable \u2014 retry" }, "batch_metadata": {} } ``` ### batch.accepted The processor accepted the batch for settlement. ```json { "id": "batch_6Yh3Uj8Ik2Ol", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv", "terminal_id": "term_5Hj3Kl8Mn2Pq6Rt", "processor_name": "tsys", "processor_config_id": "pcfg_4Df7Gh1Jk8Lm", "status": "accepted", "captured_amount": 128400, "refunded_amount": 5000, "transaction_count": 37, "processor_batch_id": "TSYS-BATCH-000481", "opened_at": "2026-01-15T08:00:00Z", "closed_at": "2026-01-15T20:00:00Z", "response": null, "batch_metadata": {} } ``` ### batch.partially\_accepted The processor accepted the batch with some rejected records. ```json { "id": "batch_6Yh3Uj8Ik2Ol", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv", "terminal_id": "term_5Hj3Kl8Mn2Pq6Rt", "processor_name": "tsys", "processor_config_id": "pcfg_4Df7Gh1Jk8Lm", "status": "partially_accepted", "captured_amount": 128400, "refunded_amount": 5000, "transaction_count": 37, "processor_batch_id": "TSYS-BATCH-000481", "opened_at": "2026-01-15T08:00:00Z", "closed_at": "2026-01-15T20:00:00Z", "response": { "accepted": 35, "rejected": 2 }, "batch_metadata": {} } ``` ### batch.edited A batch's transactions were edited before close. ```json { "id": "batch_6Yh3Uj8Ik2Ol", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv", "terminal_id": "term_5Hj3Kl8Mn2Pq6Rt", "processor_name": "tsys", "processor_config_id": "pcfg_4Df7Gh1Jk8Lm", "status": "open", "captured_amount": 128400, "refunded_amount": 5000, "transaction_count": 36, "processor_batch_id": "TSYS-BATCH-000481", "opened_at": "2026-01-15T08:00:00Z", "closed_at": null, "response": null, "batch_metadata": {} } ``` ### batch.submitted A batch was submitted to the processor for settlement. ```json { "id": "batch_6Yh3Uj8Ik2Ol", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv", "terminal_id": "term_5Hj3Kl8Mn2Pq6Rt", "processor_name": "tsys", "processor_config_id": "pcfg_4Df7Gh1Jk8Lm", "status": "submitted", "captured_amount": 128400, "refunded_amount": 5000, "transaction_count": 37, "processor_batch_id": "TSYS-BATCH-000481", "opened_at": "2026-01-15T08:00:00Z", "closed_at": null, "response": null, "batch_metadata": {} } ``` ## Account Events ### account.created A new account was created. ```json { "id": "acct_2Nk9Lm4Pq7Rs1Tv", "available_processor_configs": [], "tax_id": null, "mcc": "5812", "type": "merchant", "status": "active", "name": "Blue Bottle Coffee", "description": "Specialty coffee roaster", "parent_account_ids": [ "acct_0Pp1Qq2Rr3Ss4Tt" ], "address": { "street_line1": "1500 Market St", "street_line2": "Suite 200", "city": "San Francisco", "state": "CA", "zip": "94103", "country": "US" }, "surcharge_rate": null, "surcharge_confirmation_required": null, "surcharge_basis": null, "tax_rate": null, "tax_basis": null, "created_at": "2026-01-15T09:30:00Z", "deleted_at": null, "updated_at": null } ``` ### account.updated An account's details were updated. ```json { "id": "acct_2Nk9Lm4Pq7Rs1Tv", "available_processor_configs": [], "tax_id": null, "mcc": "5812", "type": "merchant", "status": "active", "name": "Blue Bottle Coffee Co.", "description": "Specialty coffee roaster", "parent_account_ids": [ "acct_0Pp1Qq2Rr3Ss4Tt" ], "address": { "street_line1": "1500 Market St", "street_line2": "Suite 200", "city": "San Francisco", "state": "CA", "zip": "94103", "country": "US" }, "surcharge_rate": null, "surcharge_confirmation_required": null, "surcharge_basis": null, "tax_rate": null, "tax_basis": null, "created_at": "2026-01-15T09:30:00Z", "deleted_at": null, "updated_at": null } ``` ### account.blocked An account was blocked (deactivated). ```json { "id": "acct_2Nk9Lm4Pq7Rs1Tv", "available_processor_configs": [], "tax_id": null, "mcc": "5812", "type": "merchant", "status": "blocked", "name": "Blue Bottle Coffee", "description": "Specialty coffee roaster", "parent_account_ids": [ "acct_0Pp1Qq2Rr3Ss4Tt" ], "address": { "street_line1": "1500 Market St", "street_line2": "Suite 200", "city": "San Francisco", "state": "CA", "zip": "94103", "country": "US" }, "surcharge_rate": null, "surcharge_confirmation_required": null, "surcharge_basis": null, "tax_rate": null, "tax_basis": null, "created_at": "2026-01-15T09:30:00Z", "deleted_at": null, "updated_at": null } ``` ### account.unblocked A blocked account was reactivated. ```json { "id": "acct_2Nk9Lm4Pq7Rs1Tv", "available_processor_configs": [], "tax_id": null, "mcc": "5812", "type": "merchant", "status": "active", "name": "Blue Bottle Coffee", "description": "Specialty coffee roaster", "parent_account_ids": [ "acct_0Pp1Qq2Rr3Ss4Tt" ], "address": { "street_line1": "1500 Market St", "street_line2": "Suite 200", "city": "San Francisco", "state": "CA", "zip": "94103", "country": "US" }, "surcharge_rate": null, "surcharge_confirmation_required": null, "surcharge_basis": null, "tax_rate": null, "tax_basis": null, "created_at": "2026-01-15T09:30:00Z", "deleted_at": null, "updated_at": null } ``` ### account.deleted An account was deleted (soft-deleted). ```json { "id": "acct_2Nk9Lm4Pq7Rs1Tv" } ``` ## Terminal Events ### terminal.created A new terminal was created. ```json { "terminal_id": "term_5Hj3Kl8Mn2Pq6Rt", "name": "Front Counter \u2014 Register 1", "description": "Android EMV COTS device", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv", "mid": "445566778899", "tid": "TERM0001", "store_number": "SN-COTS-00481", "bin": "412345", "vid": null, "agent_bank_number": null, "merchant_category_code": "5812", "terminal_capability": "5", "currency_code": "USD", "country_code": "US", "processor_config_id": "pcfg_4Df7Gh1Jk8Lm", "status": "active", "created_at": "2026-01-15T09:30:00Z", "deleted_at": null, "updated_at": null, "merchant_name": "Blue Bottle Coffee", "transactions": [], "surcharge_rate": null, "surcharge_confirmation_required": null, "surcharge_basis": null, "tax_rate": null, "tax_basis": null, "var_sheet": null, "batching": null } ``` ### terminal.updated A terminal's configuration was updated. ```json { "terminal_id": "term_5Hj3Kl8Mn2Pq6Rt", "name": "Front Counter \u2014 Register 2", "description": "Android EMV COTS device", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv", "mid": "445566778899", "tid": "TERM0001", "store_number": "SN-COTS-00481", "bin": "412345", "vid": null, "agent_bank_number": null, "merchant_category_code": "5812", "terminal_capability": "5", "currency_code": "USD", "country_code": "US", "processor_config_id": "pcfg_4Df7Gh1Jk8Lm", "status": "active", "created_at": "2026-01-15T09:30:00Z", "deleted_at": null, "updated_at": null, "merchant_name": "Blue Bottle Coffee", "transactions": [], "surcharge_rate": null, "surcharge_confirmation_required": null, "surcharge_basis": null, "tax_rate": null, "tax_basis": null, "var_sheet": null, "batching": null } ``` ### terminal.blocked A terminal was blocked (deactivated). ```json { "terminal_id": "term_5Hj3Kl8Mn2Pq6Rt", "name": "Front Counter \u2014 Register 1", "description": "Android EMV COTS device", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv", "mid": "445566778899", "tid": "TERM0001", "store_number": "SN-COTS-00481", "bin": "412345", "vid": null, "agent_bank_number": null, "merchant_category_code": "5812", "terminal_capability": "5", "currency_code": "USD", "country_code": "US", "processor_config_id": "pcfg_4Df7Gh1Jk8Lm", "status": "blocked", "created_at": "2026-01-15T09:30:00Z", "deleted_at": null, "updated_at": null, "merchant_name": "Blue Bottle Coffee", "transactions": [], "surcharge_rate": null, "surcharge_confirmation_required": null, "surcharge_basis": null, "tax_rate": null, "tax_basis": null, "var_sheet": null, "batching": null } ``` ### terminal.unblocked A blocked terminal was reactivated. ```json { "terminal_id": "term_5Hj3Kl8Mn2Pq6Rt", "name": "Front Counter \u2014 Register 1", "description": "Android EMV COTS device", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv", "mid": "445566778899", "tid": "TERM0001", "store_number": "SN-COTS-00481", "bin": "412345", "vid": null, "agent_bank_number": null, "merchant_category_code": "5812", "terminal_capability": "5", "currency_code": "USD", "country_code": "US", "processor_config_id": "pcfg_4Df7Gh1Jk8Lm", "status": "active", "created_at": "2026-01-15T09:30:00Z", "deleted_at": null, "updated_at": null, "merchant_name": "Blue Bottle Coffee", "transactions": [], "surcharge_rate": null, "surcharge_confirmation_required": null, "surcharge_basis": null, "tax_rate": null, "tax_basis": null, "var_sheet": null, "batching": null } ``` ### terminal.deleted A terminal was deleted (soft-deleted). ```json { "terminal_id": "term_5Hj3Kl8Mn2Pq6Rt", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv" } ``` ## Location Events ### location.created A new location was created. ```json { "id": "loc_7Bd4Cf9Gh2Jk5Lm", "name": "Ferry Building", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv", "address": { "street_line1": "1500 Market St", "street_line2": "Suite 200", "city": "San Francisco", "state": "CA", "zip": "94103", "country": "US" }, "phone": "+14155551234", "email": "ferry@bluebottle.example", "status": "active", "processor_config_id": "pcfg_4Df7Gh1Jk8Lm", "country_code": "US", "currency": "USD", "terminal_id": "term_5Hj3Kl8Mn2Pq6Rt", "metadata": null, "surcharge_rate": null, "surcharge_confirmation_required": null, "surcharge_basis": null, "tax_rate": null, "tax_basis": null, "created_at": null, "updated_at": null, "deleted_at": null } ``` ### location.updated A location's details were updated. ```json { "id": "loc_7Bd4Cf9Gh2Jk5Lm", "name": "Ferry Building Plaza", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv", "address": { "street_line1": "1500 Market St", "street_line2": "Suite 200", "city": "San Francisco", "state": "CA", "zip": "94103", "country": "US" }, "phone": "+14155551234", "email": "ferry@bluebottle.example", "status": "active", "processor_config_id": "pcfg_4Df7Gh1Jk8Lm", "country_code": "US", "currency": "USD", "terminal_id": "term_5Hj3Kl8Mn2Pq6Rt", "metadata": null, "surcharge_rate": null, "surcharge_confirmation_required": null, "surcharge_basis": null, "tax_rate": null, "tax_basis": null, "created_at": null, "updated_at": null, "deleted_at": null } ``` ### location.blocked A location was blocked (deactivated). ```json { "id": "loc_7Bd4Cf9Gh2Jk5Lm", "name": "Ferry Building", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv", "address": { "street_line1": "1500 Market St", "street_line2": "Suite 200", "city": "San Francisco", "state": "CA", "zip": "94103", "country": "US" }, "phone": "+14155551234", "email": "ferry@bluebottle.example", "status": "blocked", "processor_config_id": "pcfg_4Df7Gh1Jk8Lm", "country_code": "US", "currency": "USD", "terminal_id": "term_5Hj3Kl8Mn2Pq6Rt", "metadata": null, "surcharge_rate": null, "surcharge_confirmation_required": null, "surcharge_basis": null, "tax_rate": null, "tax_basis": null, "created_at": null, "updated_at": null, "deleted_at": null } ``` ### location.unblocked A blocked location was reactivated. ```json { "id": "loc_7Bd4Cf9Gh2Jk5Lm", "name": "Ferry Building", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv", "address": { "street_line1": "1500 Market St", "street_line2": "Suite 200", "city": "San Francisco", "state": "CA", "zip": "94103", "country": "US" }, "phone": "+14155551234", "email": "ferry@bluebottle.example", "status": "active", "processor_config_id": "pcfg_4Df7Gh1Jk8Lm", "country_code": "US", "currency": "USD", "terminal_id": "term_5Hj3Kl8Mn2Pq6Rt", "metadata": null, "surcharge_rate": null, "surcharge_confirmation_required": null, "surcharge_basis": null, "tax_rate": null, "tax_basis": null, "created_at": null, "updated_at": null, "deleted_at": null } ``` ### location.deleted A location was deleted (soft-deleted). ```json { "id": "loc_7Bd4Cf9Gh2Jk5Lm", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv" } ``` ## API Key Events ### api\_key.created A new [API key](/docs/appendix/glossary#api-key "Secret header credential for REST calls; v5 keys use scoped permissions.") was issued. ```json { "id": "key_1Ab2Cd3Ef4Gh5Ij", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv", "name": "Production integration", "status": "active", "key": "****4x9Q", "key_last4": "4x9Q", "expires_at": null, "created_at": "2026-01-15T09:30:00Z" } ``` ### api\_key.revoked An API key was revoked (denied at auth until reinstated). ```json { "id": "key_1Ab2Cd3Ef4Gh5Ij", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv", "name": "Production integration", "status": "revoked", "key": "****4x9Q", "key_last4": "4x9Q", "expires_at": null, "created_at": "2026-01-15T09:30:00Z" } ``` ### api\_key.reinstated A previously revoked API key was reinstated. ```json { "id": "key_1Ab2Cd3Ef4Gh5Ij", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv", "name": "Production integration", "status": "active", "key": "****4x9Q", "key_last4": "4x9Q", "expires_at": null, "created_at": "2026-01-15T09:30:00Z" } ``` ### api\_key.deleted An API key was deleted. ```json { "id": "key_1Ab2Cd3Ef4Gh5Ij", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv", "name": "Production integration", "status": "deleted", "key": "****4x9Q", "key_last4": "4x9Q", "expires_at": null, "created_at": "2026-01-15T09:30:00Z" } ``` ## Credential Events ### credential.created A new merchant credential was created. ```json { "id": "cred_7Kl8Mn9Op0Qr1St", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv", "code": "bluebottle-ferry", "pin": "****", "is_active": true, "created_at": "2026-01-15T09:30:00Z" } ``` ### credential.blocked A merchant credential was blocked (deactivated). ```json { "id": "cred_7Kl8Mn9Op0Qr1St", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv", "code": "bluebottle-ferry", "pin": "****", "is_active": false, "created_at": "2026-01-15T09:30:00Z" } ``` ### credential.unblocked A blocked merchant credential was reactivated. ```json { "id": "cred_7Kl8Mn9Op0Qr1St", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv", "code": "bluebottle-ferry", "pin": "****", "is_active": true, "created_at": "2026-01-15T09:30:00Z" } ``` ### credential.deleted A merchant credential was deleted. ```json { "id": "cred_7Kl8Mn9Op0Qr1St", "account_id": "acct_2Nk9Lm4Pq7Rs1Tv", "code": "bluebottle-ferry", "pin": "****", "is_active": false, "created_at": "2026-01-15T09:30:00Z" } ``` ## Event Processing ### Identifying the event Because the event type is **not** in the body, don't `switch` on a body field. Instead: 1. **Recommended — subscribe per event type.** Point one endpoint at each event type (or category) you care about in the developer portal. The endpoint's URL then tells you what it received. 2. **In-body discriminators.** Transactions carry `transaction_type` + `status`; batches carry `status`. Lifecycle events for accounts, terminals, locations, API keys, and credentials do **not** carry the verb in the body (`terminal.created` and `terminal.updated` have identical shapes), so rely on the endpoint subscription for those. ### Idempotency Delivery is **at-least-once** — the same message may arrive more than once (e.g. on retry). **Deduplicate on the `svix-id` header**: it is the canonical delivery id and stays _constant across every retry_ of a message, so it's the key to track processed events (store it, e.g. in Redis with a 24-hour expiry, and skip anything you've already seen). The transaction `event_id` field is a separate, application-level correlation id for the underlying transaction event — useful for tying a webhook back to a transaction, but **not** the delivery-dedup key. ### Event ordering Events are generally delivered in the order they occurred, but retries can cause out-of-order delivery. Use the timestamps in the payload / `svix-timestamp` header if you need strict ordering. ### Event filtering Configure filters per endpoint in the developer portal: * **Event type** — subscribe to specific event types (recommended for production). * **Transaction type** — branch on `transaction_type` (`sale`, `auth`, `capture`, `refund`, `reverse`, `tip_adjust`, `incremental_auth`). * **Status** — branch on `status` (`authorized`, `captured`, `declined`, `refunded`, `reversed`, …). * **Processor** — branch on `processor` (`tsys`, `payroc`, …). ## Complete Event List | Event | Description | Category | | -------------------------- | ------------------------------------------------------------------ | ----------- | | `transaction.authorize` | Card authorized — funds held, not yet captured. | transaction | | `transaction.sale` | Sale — card authorized and captured in one step. | transaction | | `transaction.capture` | A prior authorization was captured for settlement. | transaction | | `transaction.cancel` | Transaction cancelled before settlement. | transaction | | `transaction.create` | A transaction record was created. | transaction | | `transaction.reverse` | Authorization voided/undone after auth, before settlement. | transaction | | `transaction.refund` | Funds refunded to the cardholder after settlement. | transaction | | `transaction.increment` | An existing authorization amount was increased (incremental auth). | transaction | | `transaction.tip_adjust` | A tip was added or changed on a captured transaction. | transaction | | `batch.opened` | A settlement batch was opened. | batch | | `batch.rejected` | The processor rejected the batch. | batch | | `batch.accepted` | The processor accepted the batch for settlement. | batch | | `batch.partially_accepted` | The processor accepted the batch with some rejected records. | batch | | `batch.edited` | A batch's transactions were edited before close. | batch | | `batch.submitted` | A batch was submitted to the processor for settlement. | batch | | `account.created` | A new account was created. | account | | `account.updated` | An account's details were updated. | account | | `account.blocked` | An account was blocked (deactivated). | account | | `account.unblocked` | A blocked account was reactivated. | account | | `account.deleted` | An account was deleted (soft-deleted). | account | | `terminal.created` | A new terminal was created. | terminal | | `terminal.updated` | A terminal's configuration was updated. | terminal | | `terminal.blocked` | A terminal was blocked (deactivated). | terminal | | `terminal.unblocked` | A blocked terminal was reactivated. | terminal | | `terminal.deleted` | A terminal was deleted (soft-deleted). | terminal | | `location.created` | A new location was created. | location | | `location.updated` | A location's details were updated. | location | | `location.blocked` | A location was blocked (deactivated). | location | | `location.unblocked` | A blocked location was reactivated. | location | | `location.deleted` | A location was deleted (soft-deleted). | location | | `api_key.created` | A new API key was issued. | api\_key | | `api_key.revoked` | An API key was revoked (denied at auth until reinstated). | api\_key | | `api_key.reinstated` | A previously revoked API key was reinstated. | api\_key | | `api_key.deleted` | An API key was deleted. | api\_key | | `credential.created` | A new merchant credential was created. | credential | | `credential.blocked` | A merchant credential was blocked (deactivated). | credential | | `credential.unblocked` | A blocked merchant credential was reactivated. | credential | | `credential.deleted` | A merchant credential was deleted. | credential | ## See also * [Setting up Webhooks](/docs/webhooks/setting-up-webhooks) — configuring endpoints, event subscriptions, and signature verification # Online PIN Validation Koard supports **[online PIN](/docs/appendix/glossary#online-pin "PIN validated in real time by the issuer during authorization (not offline PIN).") only**. When a PIN is required, it is validated in real time against the issuer during the authorization request — Koard does not support offline PIN, where the PIN would be verified locally on the device without issuer involvement. The encrypted PIN block is forwarded to the acquirer and issuer as part of the authorization. The issuer validates the PIN against the cardholder's account and will hard decline the transaction if the PIN is incorrect — this decision is made entirely by the issuer and acquirer, not by Koard. When a cardholder enters a PIN during a [Tap to Pay on iPhone](/docs/appendix/glossary#tap-to-pay "Apple contactless acceptance on iPhone without extra hardware.") transaction, Apple encrypts the PIN data before it leaves the device. Koard handles the full decryption and validation flow online — the encrypted PIN never passes through your application unprotected. ## How It Works 1. The iOS SDK captures the cardholder's PIN and returns encrypted cardholder data, encrypted PIN data, and a transaction ID to Koard. 2. Koard calls Apple's [Proximity Payment Service](/docs/appendix/glossary#proximity-payment-service "Apple service that exchanges encrypted Tap to Pay data for single-use decryption keys.") to exchange the encrypted data for single-use decryption keys. 3. Apple returns keys scoped to that transaction. Koard validates and decrypts the data, then forwards the PIN block to the payment processor in the authorization request. ## Supported Scenarios | Scenario | PIN Captured | Notes | | --------------------------- | ------------ | ------------------------------------------------------------ | | Cardholder data only | No | Standard contactless — no PIN required | | Cardholder data + PIN | Yes | PIN collected inline during the tap | | Cardholder data + PIN token | Yes | PIN collected and tokenized | | PIN fallback | Yes | Used when the card requires PIN but cannot use standard flow | # Glossary Canonical definitions for Koard docs. Hover a linked term in any guide for a short definition, or jump here for the full entry. ## Platform ### Koard Payment platform that helps PSPs and ISVs accept Tap to Pay on iPhone and card-present payments via SDKs, REST APIs, and merchant management. ### PSP (Payment Service Provider) Company that serves multiple merchants with payment processing. A primary Koard customer type. ### ISV (Independent Software Vendor) Software company building payment or POS applications that integrate Koard for Tap to Pay and processing. ### Partner Top-level account in the MMS that owns descendant merchant accounts, terminals, and API keys. ### MMS (Merchant Management System) Koard portal for onboarding merchants, terminals, locations, credentials, and API keys. * **UAT:** * **Production:** ### UAT Isolated sandbox environment (`api.uat.koard.com` / `app.uat.koard.com`). Transactions do not move money or appear in live card or merchant histories. ### Sandbox Koard test environment for exercising features without live funds. Distinct from an Apple Sandbox Apple Account used for Tap to Pay certification. ### Production / Live mode Live environment where activated merchant accounts accept real payments (`api.koard.com` / `app.koard.com`). ## Accounts & hierarchy Tax and surcharge defaults resolve in this order: **location → terminal → account**. The first non-null value wins; `null` inherits and explicit zero overrides. ### Account Merchant or partner entity in Koard. ### Merchant Business onboarded under a partner to accept payments. Creation requires a name, tax ID, MCC, and a US business address. Country must remain US on address updates; a merchant update cannot move the account to an unsupported jurisdiction. Tax ID and merchant payment defaults do not belong in partner creation. See [Creating Merchant and Partner Accounts](/docs/getting-started-with-koard/creating-accounts). ### Location Physical or logical place under a merchant where terminals accept payments. Can override account-level tax and surcharge settings. ### location\_id Unique identifier for a Location. Used in REST paths (`/v1/locations/{location_id}`), list filters, webhook payloads, and SDKs (for example `setActiveLocation(locationId)` before `enrollDevice` / payments). ### Terminal Payment endpoint (often an iPhone running Tap to Pay) tied to processor credentials (MID/TID or Partner ID) and optionally a terminal profile. ### Terminal Profile Reusable settings for terminals: tipping, surcharge flags, receipts, timeouts, and UI preferences. ### Merchant Credentials Credentials merchants use to authenticate the Koard SDK and process payments; scoped to locations and terminals. ### MCC (Merchant Category Code) Four-digit ISO business-type code required at merchant onboarding (for example `5812` for restaurants). ## Payment operations **Tap-initiated (card-present):** Sale and Preauth require encrypted card data from Tap to Pay or another compliant reader. **Follow-ups (card-not-present):** Capture, Incremental Auth, Tip Adjust, Reverse, Refund, and Confirm do not require another tap. ### Sale One-step authorize + capture. Funds are collected immediately; status typically ends as `captured`. ### Preauth (Preauthorization) Authorization hold without capture. Use when the final total may change; you must later capture or reverse. ### Capture Settle (collect) funds from a prior authorization. Optional amount and breakdown for tips and surcharge reconciliation. ### Incremental Auth Increase an existing authorization hold by a delta amount. Status remains `authorized`. A declined increment reverts to the last successfully authorized amount. ### Tip Adjust Update gratuity before settlement (for example tip-on-receipt). Does not recompute surcharge. ### Reverse / Void Release uncaptured authorized funds. Use instead of refund when money was never captured. ### Refund Return funds on a captured transaction (full or partial). Distinct from reverse. ### Confirm SDK/API step that finalizes a sale after cardholder surcharge disclosure when the SDK status is `surchargePending` (webhook/API payloads use `surcharge_pending`). ### PaymentBreakdown SDK object for subtotal, tax, tip, and nested Surcharge details sent with payment requests. ### Surcharge Fee added to eligible credit-card transactions to offset processing costs. Not applied to debit. Can be automatic (processor) or custom (for example BIN-based). ### surchargePending SDK sale status when automatic surcharge applies and cardholder confirmation is required before finalization. ### surcharge\_pending Webhook and REST API transaction status when surcharge confirmation is required (`surcharge_pending`). Distinct from the SDK enum value `surchargePending`. ### Tip capture mode How tip was collected: `tip_at_sale` (on the original sale) or `tip_adjust` (added or changed after the sale). ### Tax rate / Tax basis Hierarchical configuration defaults for what tax is calculated on and at what percentage. Separate from per-transaction `taxAmount` / `taxRate` on the breakdown. ## Identifiers Do not conflate these IDs. ### event\_id Client-generated UUID4 idempotency key for a single API attempt. Replays with the same ID are rejected as duplicates. Similar role to RRN for one processor call. ### transaction\_id Koard-issued ID for the full payment lifecycle. Shared across capture, tip adjust, incremental auth, reverse, and refund on the same payment. ### MID (Merchant ID) Processor-assigned merchant identifier used for routing and batching (often with TID). ### TID (Terminal ID) Processor-assigned per-terminal identifier. Batches are typically unique per MID/TID combination. ### VIN (Vendor ID) Vendor identifier from some processors, entered with MID/TID at terminal setup. Some processors use Partner ID instead. ### Partner ID / Processing Terminal ID Alternate boarding identifier used by processors like Payroc in place of classic MID/TID/VIN. ### processor\_batch\_id Batch ID unique per MID/TID used when informing Koard of settlements from an external batching system. ### BIN Bank Identification Number (card prefix). Used for custom BIN-based surcharging and (as Acquirer BIN) on some processor VAR sheets such as TSYS. ### DBA Doing Business As — the trade name shown on cardholder statements and processor records (for example `doing_business_as_name` on Silverflow). ### RRN (Retrieval Reference Number) Traditional card-network identifier for a single processor attempt; conceptually similar to `event_id`. ## Transaction states These are REST status values. SDK enums can additionally represent reader and UI states. See [Payment Lifecycle](/docs/payments/payment-lifecycle). ### pending Outcome or external input is still pending. Reconcile before another operation. ### surcharge\_pending Cardholder surcharge confirmation is required. ### authorized Funds are held, awaiting supported capture or reversal. ### captured Sale or capture succeeded; settlement follows. ### settled Settlement was recorded. ### declined The attempt was declined. ### error An error was recorded. Reconcile any uncertain processor outcome before resubmitting. ### reversed A reversal succeeded; hold release timing depends on the issuer. ### refunded A refund succeeded; inspect amounts for partial refunds. ### canceled The payment flow was canceled. ## Boarding & processors ### Boarding Onboarding a merchant or terminal onto a payment processor with credentials from a VAR sheet (via MMS UI or API). ### VAR Sheet Processor-provided parameter packet (MID, TID, MCC, Group ID, and related fields) required to board a terminal. Shape differs by processor (TSYS, Fiserv, Elavon, Worldpay, Payroc, Silverflow, Cybersource). ### Processor Config Koard-side configuration selecting which acquiring platform or flavor a merchant uses (for example Fiserv Rapid Connect front-ends via `processor_config_id`). ### Host capture Settlement model where the processor host manages the batch and settles on its cutoff (for example Fiserv today). Merchant-driven open/close may be unavailable. ### TPPID Fiserv Rapid Connect ID for the certified integrator/SDK build (owned by Koard), not a merchant VAR-sheet field. ### Datawire / DID Transport/ID used for some Fiserv terminals when Datawire is enabled; may be on the VAR packet or provisioned by Koard. ### Passthrough EMV Flow where Koard only forwards EMV data and later receives the final outcome from the PSP. `PUT /v1/transactions/{id}` is reserved for this; not for directly managed processors such as TSYS, Elavon, Fiserv, or Worldpay. ## Apple & Tap to Pay ### Tap to Pay on iPhone Apple contactless acceptance on iPhone (XS or later, iOS 17.4+) without extra hardware. Requires entitlement, provisioning, and Koard SDK integration. ### Tap to Pay on Android Contactless card acceptance on NFC-enabled Android devices (Android 13+) using the Visa Tap to Pay Ready kernel and Koard SDK — no external reader hardware required. ### Tap to Pay Entitlement Apple Managed Capability that must be requested and approved before building Tap to Pay–enabled apps. ### L3 Certification Apple Level-3 certification path that Koard’s partnership helps PSPs and ISVs bypass for faster time-to-market. ### Sandbox Apple Account Dedicated Apple tester account signed in on a test iPhone for certification and QA. Production Apple IDs cannot run Sandbox Tap to Pay. ### Apple CERT environment Apple certification environment used after development certificate approval when validating Tap to Pay flows. ### Apple Business Register Apple hub for PSP Tap to Pay documentation, registration, and certification resources (PSP access). ### Proximity Payment Service Apple service Koard calls to exchange encrypted Tap to Pay cardholder/PIN data for single-use decryption keys. ### KEK exchange Key-encryption-key setup process referenced in Apple partnership and environment configuration phases. ### prepare() SDK call that prepares the device/session for Tap to Pay before running payments; required for successful taps. ### Online PIN PIN validated in real time by the issuer during authorization. Koard supports online PIN only (not offline PIN). ## Settlement ### Batch Collection of transactions grouped for settlement, typically per MID/TID. Can be opened, edited, submitted, accepted, or rejected. ### Batch cutoff / Batch window Time period when transactions are included in a batch, and the time when the batch closes for settlement. ### Settlement Transfer of funds from the payment processor to the merchant account after batch processing. ### Automated Batch Scheduling Koard-managed timing and retries for opening and closing batches when Koard is the authorization layer. ## API & security ### API Key Secret header credential for REST calls (`x-koard-apikey`). v5 keys use explicit scoped permissions (`resource:action`). Partner keys can manage sub-accounts; merchant keys are limited. ### Scoped Permissions Explicit grants on v5 API keys (for example `payments:capture`). Holding a write permission does not imply the matching read permission. Macros: `all` and `legacy_all`. ### Webhook HTTPS POST callback from Koard when events occur. Verify the HMAC-SHA256 signature, respond with HTTP 200 quickly, and handle deliveries idempotently. Transaction events fire on the attempt — read `status` and `status_reason` for the outcome. ### SVIX Webhook infrastructure provisioned with v2 account creation alongside the account organization in MMS. REST API keys are issued separately. ### Idempotency Safe retry pattern using a client-generated `event_id` so network failures do not create duplicate charges. Lookup: `GET /v1/transactions/event/{event_id}`. ### PCI DSS Payment Card Industry Data Security Standard. Koard is built to meet PCI DSS practices including encryption and tokenization. ### Tokenization Replacing sensitive payment data with tokens to reduce PCI scope and exposure. # Developing with Apple Comprehensive guidelines and best practices for developing [Tap to Pay on iPhone](/docs/appendix/glossary#tap-to-pay "Apple contactless acceptance on iPhone without extra hardware.") applications with Koard and Apple's payment technologies. ## Overview Developing Tap to Pay on iPhone applications requires careful attention to security, environment configuration, and compliance with Apple's requirements. This guide covers essential security measures, environment setup, and testing procedures to ensure a secure and compliant integration. ## Security Considerations ### Cardholder Data Protection Given the sensitive nature of cardholder data processed through Tap to Pay on iPhone, Koard has implemented multiple security measures to ensure secure development and operation: * **Secure SDK Architecture**: All card data is handled securely through Apple's ProximityReader framework * **Encrypted Communication**: All API communications use industry-standard encryption * **[Tokenization](/docs/appendix/glossary#tokenization "Replacing sensitive payment data with tokens to reduce PCI scope.")**: Sensitive payment data is tokenized and never stored in plain text * **[PCI DSS](/docs/appendix/glossary#pci-dss "Payment Card Industry Data Security Standard for cardholder data protection.") Compliance**: Koard maintains PCI DSS Level 1 compliance standards ### Entitlement Verification Before initiating any Tap to Pay functionality, verify that your application has the correct Tap to Pay on iPhone entitlement. **Check Entitlement Status** Configure the entitlement and provisioning for the app's bundle ID in Xcode and Apple Developer. Test reader preparation on a supported physical iPhone after initialization, merchant login, and active-location selection. Handle linking and preparation errors through the SDK; hardware support alone does not verify an entitlement. See [Adding Tap to Pay](/docs/setting-up-the-ios-sdk/adding-support-for-tap-to-pay-on-iphone) and [Running Payments](/docs/setting-up-the-ios-sdk/running-payments). ## Environment Configuration ### UAT and Production Environments Use the SDK's `.uat` environment with a UAT SDK client key for integration testing, and `.production` with the matching production key for live merchant payments. The published SDK also supports `.custom(String)` when Koard supplies a specific backend URL. It does not expose a `.cert` enum case. Apple sandbox account setup and app entitlements are separate from Koard's backend environment selection. Use a dedicated test device and the [Sandbox Apple Account guide](/docs/setting-up-the-ios-sdk/creating-a-sandbox-apple-account) for UAT Tap to Pay tests. ### Environment Selection To switch between environments, configure your SDK initialization: ```swift let options = KoardOptions( environment: .uat, // .production for live merchant payments loggingLevel: .debug ) KoardMerchantSDK.shared.initialize( options: options, apiKey: "your-api-key" ) ``` ## Check merchant linking status Use `GET /v1/accounts/{account_id}/linkstatus` to read the merchant's current Apple Tap to Pay linking status. Supply the merchant's Koard account ID, rather than the processor's MID or an Apple ID. Your API key needs `accounts:read` and access to that merchant account. The merchant itself and its account ancestors can query it. A PSP account is not a valid target. ```sh curl 'https://api.uat.koard.com/v1/accounts/YOUR_MERCHANT_ACCOUNT_ID/linkstatus' \ -H 'X-Koard-apikey: YOUR_API_KEY' ``` The response is always an array, including when there is just one configured provider: ```json [ { "account_id": "YOUR_MERCHANT_ACCOUNT_ID", "merchant_name": "Merchant Coffee Shop", "iso_name": "koard", "status": "LINKED", "linked": true, "created_date": null } ] ``` | `status` | `linked` | Meaning | | ------------- | -------- | ------------------------------------------------------ | | `LINKED` | `true` | Apple reports that the merchant is linked. | | `NOT_LINKED` | `false` | Apple reports that the merchant is not linked. | | `DEACTIVATED` | `false` | Apple reports that the merchant's link is deactivated. | `merchant_name` is the target merchant's name. `iso_name` identifies the configured PSP. Multiple entries are possible. The call reads current status and does not link, relink, or accept terms for the merchant. An empty array means associated configurations exist but none has management credentials available for a lookup. It does not mean Apple returned `NOT_LINKED`. If there is no associated active configuration, the API returns 503. A missing or inaccessible merchant returns 404; a non-merchant target returns 400. Lookup failures return an error instead of a partial array or a fabricated status. See the [Accounts API reference](/api-reference/accounts) for the response schema and error codes. This merchant linking status is separate from a PSP's Apple configuration and reporting inventory. ## Sandbox Testing ### Sandbox Tester Account Setup If your SDK or API developers need to connect to Apple's sandbox through your UAT integration, you must create a Sandbox Tester Account. This account allows you to test Tap to Pay functionality without processing real transactions. ### Creating a Sandbox Tester Account Follow these steps to create a sandbox tester account: 1. **Sign in to App Store Connect** * Navigate to [App Store Connect](https://appstoreconnect.apple.com) * Sign in with your Apple Developer account credentials 2. **Access Sandbox Testers** * On the homepage, click **Users and Access** * In the top navigation, click **Sandbox** * Click the add button (+) * If this is your first time adding sandbox testers, click **Create Test Accounts** 3. **Complete Tester Information** * Enter a first and last name for your tester * Enter an email address that: * Has not been used as an Apple Account * Has not been used to purchase iTunes or App Store content * Consider creating a dedicated email address for each sandbox tester * Enter a strong password that meets Apple's requirements * Choose an App Store country or region 4. **Email Subaddressing (Optional)** If your email service provider supports email subaddressing with a plus sign (+), you can use subaddresses of a sandbox-specific address for multiple testers. For example: * Base email: `billjames2@icloud.com` * Subaddresses: `billjames2+UK@icloud.com`, `billjames2+US@icloud.com`, `billjames2+JP@icloud.com` All communications sent to the subaddresses are also sent to the base address. 5. **Invite the Tester** * Review all information * Click **Invite** to complete the setup 6. **Configure Testing Devices** * Sign out of your Apple Account on all testing devices * Sign back in with your new sandbox tester account **Important Notes**: * Once you create a tester, you cannot edit the name, email, or password * Each test account is associated with one of 175 App Store storefronts * You can edit a tester's App Store country or region after creation to test on different storefronts using the same Sandbox account ### Additional Resources For more detailed information on creating sandbox tester accounts, see [App Store Connect Help: Create a sandbox tester account](https://help.apple.com/app-store-connect/#/dev8b57d558e). ## Best Practices ### Development Workflow 1. **Use UAT** for all internal development and testing 2. **Verify Entitlements** before attempting to use Tap to Pay functionality 3. **Implement Error Handling** for missing entitlements and other error conditions 4. **Test Thoroughly** using sandbox tester accounts before production deployment ### Security Guidelines * Never log or store sensitive cardholder data * Implement proper error handling without exposing sensitive information * Use secure communication protocols (HTTPS/TLS) * Follow Apple's security guidelines for payment applications * Regularly update your SDK to the latest version for security patches ## Testing Checklist Before deploying to production, ensure: * [ ] Application has Tap to Pay on iPhone entitlement configured * [ ] Entitlement verification is implemented and tested * [ ] Error handling for missing entitlements is in place * [ ] Sandbox tester accounts are created and configured * [ ] Testing is performed in UAT * [ ] All payment flows are tested with test cards * [ ] Production environment is properly configured before go-live ## Support and Resources ### Apple Documentation * [Apple Pay Developer Guide](https://developer.apple.com/apple-pay/) * [ProximityReader Framework Reference](https://developer.apple.com/documentation/proximityreader) * [App Store Connect Help](https://help.apple.com/app-store-connect/) * [Human Interface Guidelines](https://developer.apple.com/design/human-interface-guidelines/) ### Koard Resources * [Setting Up the Entitlement](/docs/setting-up-the-ios-sdk/setting-up-the-entitlement-for-tap-to-pay-on-iphone) * [SDK Installation Guide](/docs/setting-up-the-ios-sdk/installing-the-sdk) * [Payment Lifecycle Guide](/docs/payments/payment-lifecycle) * [Test Cards Reference](/docs/appendix/resources#test-cards) # Apple Best Practices and Guidelines ## Overview These integration patterns use the published **Koard iOS SDK 1.0.21** for ordinary merchant Tap to Pay checkout. Let the SDK manage Apple's reader; use its public readiness and payment methods in your app. ## Apple's Dual Review Process ### 1. Tap to Pay Review Follow the [entitlement setup guide](/docs/setting-up-the-ios-sdk/setting-up-the-entitlement-for-tap-to-pay-on-iphone) for your app's Tap to Pay access and provisioning. ### 2. App Store Review Use the [App Store submission guide](/docs/setting-up-the-ios-sdk/app-store-submission) to prepare the reviewer flow and the correct environment build. Consult Apple's current [App Review Guidelines](https://developer.apple.com/app-store/review/guidelines/) for submission requirements. ## User Experience Best Practices ### Feedback and User Actions Show the active merchant location in Settings and checkout. Require an explicit selection of a location belonging to the authenticated merchant with a usable linked terminal. Explain whether checkout is a sale or a preauth hold, show the final amount, and prevent duplicate payment actions while work is in progress. ### Error Handling Best Practices Keep background preparation feedback inline. An `accountNotLinked` error requires a visible terms-acceptance action; do not launch linking from a background retry. Handle customer cancellation separately from a decline or an unknown payment outcome. Preserve the saved operation ID for recovery after network failures. ## ProximityReader Framework Best Practices ### Reader Management Initialize once, log in, select the active location, and prepare before the first payment. The standard checkout demo reads `KoardMerchantSDK.shared.status`; the SDK owns the reader event subscription. Do not add a competing `readerEvents` iterator or construct a separate ProximityReader session alongside the SDK. A preparation coordinator keeps app-triggered preparation sequential and surfaces errors: ```swift import Combine import KoardSDK @MainActor final class PaymentReadiness: ObservableObject { @Published private(set) var isPreparing = false @Published private(set) var isReady = false @Published private(set) var needsAccountLinking = false @Published private(set) var message = "Reader not ready" func prepare() async { guard !isPreparing else { return } isPreparing = true defer { isPreparing = false } do { try await KoardMerchantSDK.shared.prepare() let status = KoardMerchantSDK.shared.status isReady = status.isReady needsAccountLinking = false message = status.info } catch KoardMerchantSDKError.accountNotLinked { isReady = false needsAccountLinking = true message = "Accept Tap to Pay terms to continue" } catch { isReady = false message = error.localizedDescription } } // Call only from the operator's explicit terms-acceptance action. func acceptTerms() async { guard !isPreparing else { return } isPreparing = true do { try await KoardMerchantSDK.shared.linkAccountAsync() isPreparing = false await prepare() } catch { isPreparing = false isReady = false needsAccountLinking = true message = error.localizedDescription } } } ``` Call these methods after login and location selection. Monitor public status while the view is visible using the [cancelable polling example](/docs/setting-up-the-ios-sdk/running-payments#session-management). Disable checkout during preparation, merchant/location changes, and SDK cleanup. ### Secure Payment Processing Use the SDK's `sale` and `preauth` calls to obtain the encrypted reader data and route the request. Amount arguments are integer minor currency units. Inspect `response.transaction` before displaying success; partial approval, surcharge confirmation, and pending outcomes need separate handling. See [Running Payments](/docs/setting-up-the-ios-sdk/running-payments). ## Security Best Practices ### Data Protection and Privacy Persist order references, operation IDs, transaction IDs, and approved receipt data needed by your app. Keep privileged API keys on your backend. Never put card data, PINs, session tokens, or credentials in metadata or application logs. Use the intended scoped SDK client key in the mobile app. ### Entitlement Verification Configure Tap to Pay entitlements and provisioning for the app's bundle ID. Test on a supported physical iPhone; a successful simulator build does not validate a reader entitlement or a real tap. `isReaderSupported` describes reader support, not proof that merchant linking and preparation succeeded. ## UI/UX Design Guidelines ### Payment Flow Design Keep the amount, currency, active location, and sale/preauth mode visible. Obtain any required surcharge decision using the returned disclosure and final total. Show partial approval before collecting the remainder on another card. An authorized hold must not be presented as captured or settled. ### Progress and Loading States Display preparation and transaction progress without blocking the main thread. Keep the payment button disabled during active work. Poll readiness only while the view is visible and cancel the polling task on navigation; do not accumulate untracked event listeners. ## Testing and Validation ### Comprehensive Testing Strategy Exercise login, merchant changes, invalid saved locations, reader preparation, declined linking, sale, preauth, partial approval, surcharge decisions, refunds, and cancellation. Test uncertain outcomes and transaction recovery without automatically submitting a second charge. Use the correct environment and test cards on physical hardware. ### TestFlight Preparation Archive with the intended environment scheme and matching SDK client key. Verify the same signed build, entitlement, merchant location, and reader flow that testers will use. ## App Store Review Preparation ### Documentation Requirements Provide clear reviewer steps for authentication, location selection, terms acceptance, and checkout. Explain any test-account or device setup needed to reach those screens. See [App Store submission](/docs/setting-up-the-ios-sdk/app-store-submission). ### Review Checklist - The archive uses the intended environment and provisioning. - The active location and transaction amount are visible before payment. - Linking, preparation, cancellation, and decline flows provide actionable feedback. - Receipts reflect the returned transaction state and actual amounts. - Testing includes a supported physical device. ## Performance Optimization ### Memory Management Cancel screen-owned monitoring tasks when leaving the screen. Reuse the SDK singleton and let it own reader sessions. Complete active payment/preparation work before logout or deinitialization. ### Network Optimization Prepare after merchant and location setup to move reader readiness work ahead of checkout. Reuse the saved operation ID for supported outcome recovery; do not invent a new payment after a timeout. Backend-operation success must be determined from the returned transaction, not solely from a completed network call. ## Resources and References ### Apple Documentation - [ProximityReader](https://developer.apple.com/documentation/proximityreader) - [Tap to Pay on iPhone](https://developer.apple.com/tap-to-pay/) - [App Review Guidelines](https://developer.apple.com/app-store/review/guidelines/) ### Koard Resources - [Installing the SDK](/docs/setting-up-the-ios-sdk/installing-the-sdk) - [Running Payments](/docs/setting-up-the-ios-sdk/running-payments) - [Standard Demo App](/docs/setting-up-the-ios-sdk/demo) - [SDK Lifecycle](/docs/setting-up-the-ios-sdk/sdk-lifecycle) ### Additional Support Contact [Koard Developer Support](mailto:developers@koard.com) with the SDK version, environment, transaction/operation IDs, and a sanitized description of the failure. # Payment Configurations Payment routing comes from the active location's linked terminal and processor configuration. Choose a configuration available to the merchant in the same environment, then board the terminal using that configuration's required fields. - [List available processor configurations](/docs/boarding-a-merchant/processor-configurations). - [Board or update a terminal](/docs/boarding-a-merchant/contracts). - [Select the active location](/docs/getting-started-with-koard/setting-up-the-merchant#active-location-and-terminal-routing). - [Configure tax and tips](/docs/payments/tax-and-tip-handling). - [Configure surcharging](/docs/payments/surcharging). - [Understand batch management](/docs/batch-and-settlements/running-batches). Apple terminal profiles control reader behavior; a merchant's boarded terminal contains the processor routing and identifiers. Keep these distinct. For configuration changes requiring Koard assistance, contact [support@koard.com](mailto:support@koard.com). # Resources ## SDKs and Integration Guides | Resource | Link | |---|---| | iOS SDK releases and Swift Package Manager package | [koard-ios](https://github.com/koardlabs/koard-ios) | | iOS installation | [Installing the SDK](/docs/setting-up-the-ios-sdk/installing-the-sdk) | | Android SDK and examples | [koard-android](https://github.com/koardlabs/koard-android) | | Android installation and KiC package visibility | [Installing the SDK](/docs/setting-up-the-android-sdk/installing-the-sdk#step-2-configure-androidmanifest.xml-permissions-and-package-visibility) | | Merchant and location setup | [Merchant Setup](/docs/getting-started-with-koard/setting-up-the-merchant) | | Public REST contracts | [API Reference](/api-reference) | | Payments and recovery | [Payment Lifecycle](/docs/payments/payment-lifecycle) | | Settlement and batch reports | [Running Batches](/docs/batch-and-settlements/running-batches) | | Signed webhook handling | [Webhook Setup](/docs/webhooks/setting-up-webhooks) | Use Swift Package Manager with `https://github.com/koardlabs/koard-ios`, or the released XCFramework, for iOS. The current Android dependency is: ```kotlin implementation("com.koard:koard-android-sdk:1.0.7") ``` ## Test Cards COTS contactless acceptance requires actual reader-generated EMV data. Generic keyed PAN/CVV test numbers do not simulate a card tap and are not Koard contactless certification fixtures. Use the test cards, wallet tokens, and test cases assigned by your processor or network for the selected environment and certification project. Follow [Apple Pay sandbox testing](https://developer.apple.com/apple-pay/sandbox-testing/) when provisioning a supported sandbox wallet. Confirm supported brands, card types, PIN/CVM cases, and expected responses with your certification contact. Do not substitute data from another processor's test suite. ## Environments | Environment | API URL | Use | |---|---|---| | UAT | `https://api.uat.koard.com` | Integration testing with the assigned UAT processor configuration and test credentials | | Production | `https://api.koard.com` | Approved live merchant configurations | Keep environment-specific keys, merchant identifiers, and processor configuration IDs together. A processor's separate CERT credentials are not interchangeable with Koard UAT credentials. ## Validation Checklist - Select the active merchant location in app settings and verify its linked terminal. - Test approved, declined, partial approval, cancellation, and supported refund/tip flows using assigned test cases. - Verify readiness, supported device eligibility, and the Android KiC package lookup. - Recover lost responses using a persisted event ID instead of blindly retrying. - Verify webhook signatures and duplicate delivery handling. - Reconcile transaction totals against the batch submission and processor report. - Complete the platform and processor approvals required for production. ## Primary Platform References - [Apple Tap to Pay on iPhone](https://developer.apple.com/tap-to-pay/) - [Apple ProximityReader](https://developer.apple.com/documentation/proximityreader) - [Android package visibility](https://developer.android.com/training/package-visibility/declaring) - [Svix signature verification](https://docs.svix.com/receiving/verifying-payloads/how) For merchant configuration or certification questions, contact [support@koard.com](mailto:support@koard.com). # Koard API Reference Koard API provides unified access to account onboarding, terminal management, payment facilitation, and reporting across all Koard environments. ## Base URLs | Environment | URL | | -------------------------------------------------------------------------------------------------------- | --------------------------- | | [UAT](/docs/appendix/glossary#uat "Isolated sandbox environment — transactions do not move live money.") | `https://api.uat.koard.com` | | Production | `https://api.koard.com` | UAT is an isolated sandbox environment — transactions processed there will not appear in card or merchant histories and no money moves. ## API Versioning All Koard API endpoints are versioned with a path prefix (`/v1`, `/v2`, `/v3`, etc.). The version is part of the URL path and is **required** for every request. ```bash # Example: v1 endpoint curl https://api.koard.com/v1/accounts/{account_id} \ -H "x-koard-apikey: YOUR_API_KEY" # Example: v2 endpoint curl https://api.koard.com/v2/terminals \ -H "x-koard-apikey: YOUR_API_KEY" # Example: v3 endpoint curl https://api.koard.com/v3/payments/{transaction_id}/capture \ -H "x-koard-apikey: YOUR_API_KEY" ``` ### Current Versions by Resource | Resource | Version(s) | Path Prefix | Notes | | ------------------- | ---------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- | | **Accounts** | v1, v2 | `/v1/accounts`, `/v2/accounts` | Create and manage merchant and partner accounts; issue REST API keys separately through v5 | | **Terminals** | v2 | `/v2/terminals` | Paginated listing, search, and full CRUD | | **Locations** | v1 | `/v1/locations` | Create, update, and retrieve locations | | **Transactions** | v1, v2 | `/v2/transactions`, `/v1/transactions/{id}` | Use v2 GET for filtered, paginated searches; v1 retains transaction detail and passthrough-only edits | | **Payments** | v4 | SDK-managed card-present initiation | Released SDKs supply encrypted reader data and use the supported platform flow | | **Payment Actions** | v1, v3 | `/v1/payments/{id}/…`, `/v3/payments/{id}/…` | Capture, auth increment, refund, reverse, adjust, confirm | | **Refunds** | v1, v2 | `/v1/payments/{id}/refund`, `/v2/payments/{id}/refund` | Reference-based refund or supported card-present refund | | **Batches** | v1 | `/v1/batches` | Open, close, and edit settlement batches | | **API Keys** | v5 | `/v5/apikeys` | Scoped-permission keys; see [Authentication](/api-reference/authentication) | The `/v5` surface currently exposes **API-key management only** (`/v5/apikeys`). Other resources such as payments, terminals, and accounts are served by their documented `/v1` through `/v4` paths above; there is no `/v5/payments`, `/v5/terminals`, etc. The site version selector, API path versions, and SDK release versions are independent. The default site version contains the current references. Older selectable API references are historical; shared guide pages describe the current integration. Use the API path and SDK version shown in each example. Account creation has separate **merchant** and **partner** request variants. Merchants require `tax_id`, `mcc`, and a US business address; partners omit merchant tax and payment defaults. See [Creating Merchant and Partner Accounts](/docs/getting-started-with-koard/creating-accounts). ### Version Lifecycle * **Newer versions** may change request/response schemas, add required fields, or alter default behavior. Always use the version shown in the endpoint path. * **Older versions** remain functional unless an endpoint is explicitly retired, and may not include the latest features. We recommend migrating to the latest available version for each resource. * **Endpoint retirement** is a breaking change. The transaction-search retirement below identifies the removed operation and its replacement. > **Note:** The version prefix (e.g. `/v1/`, `/v2/`, `/v3/`) is required in all request paths. Requests without a version prefix will return a `404`. ### Transaction search Use `GET /v2/transactions` for filtered transaction searches. The former `POST /v1/transactions/search` operation is retired and returns `405` after the retirement release is deployed. Existing `GET /v1/transactions` and transaction detail routes remain available. Send filters in the query string, with no JSON request body. Repeat `account_id`, `status` and `transaction_type` for multiple values. Omit unused filters; do not send empty strings or the literal string `null` for numeric or enum filters. Amounts are integer cents, and dates are integer milliseconds since epoch. ```bash curl --get 'https://api.uat.koard.com/v2/transactions' \ -H 'X-Koard-apikey: YOUR_API_KEY' \ --data-urlencode 'status=captured' \ --data-urlencode 'status=refunded' \ --data-urlencode 'min_amount=100' \ --data-urlencode 'limit=50' \ --data-urlencode 'offset=0' ``` The response contains `transactions`, `total`, `limit`, `offset` and `page`. The default page size is 50, with a maximum of 200. The old POST default was 100, so set `limit=100` explicitly if that page size is required. Replace the old JSON `account_ids` array with repeated `account_id` query parameters. An account filter can identify a merchant or a parent whose descendants should be included, subject to the caller's access. Without an account filter, v2 searches the caller's hierarchy. It does not request every account in the environment. > **Passthrough-only transaction edits:** `PUT /v1/transactions/{transaction_id}` is reserved for [PSP](/docs/appendix/glossary#psp "Company that serves multiple merchants with payment facilitation.") [passthrough EMV](/docs/appendix/glossary#passthrough-emv "Koard forwards EMV data and later receives the final outcome from the PSP.") flows where Koard is only forwarding EMV data and later receiving the final transaction outcome from the PSP. It is not available for TSYS, Elavon, Fiserv, Worldpay, or other directly managed processor flows. Unsupported processor edits return `401`, while both missing and inaccessible transactions return `404`. # Authentication All Koard API requests must be authenticated using an [API key](/docs/appendix/glossary#api-key "Secret header credential for REST calls; v5 keys use scoped permissions.") passed in the `x-koard-apikey` header. ```bash curl https://api.koard.com/v1/accounts/YOUR_ACCOUNT_ID \ -H "x-koard-apikey: YOUR_API_KEY" \ -H "Accept: application/json" ``` ## Headers | Header | Required | Value | | ---------------- | ------------------- | ------------------ | | `x-koard-apikey` | Always | Your Koard API key | | `Accept` | Always | `application/json` | | `Content-Type` | When sending a body | `application/json` | The header name is case-insensitive — `x-koard-apikey` and `X-Koard-apikey` are equivalent. ## API Keys API keys are provisioned per account. A partner-level key can manage merchants and terminals under it. A merchant-level key (a key bound to a `merchant` account) is limited to that merchant's own operations. Retrieve or rotate your API key from the [Koard MMS](https://app.koard.com) under your account settings, or manage keys programmatically via the [API Keys endpoints](/api-reference/apikeys). Keep your API key secret. Never expose it in client-side code or public repositories. ### Scoped Permissions Each v5 key carries an explicit list of permissions. A permission is a string in the form `resource:action` (for example `payments:read`, `terminals:create`) or `resource:action:subtype` (for example `accounts:create:merchant`). A key can do **only** what it has been granted — there are no implicit grants. In particular, holding a write permission does **not** imply the matching read permission. Two macro permissions exist: `all` grants every concrete permission (for trusted server-side backends), and `legacy_all` is a grandfathered bucket set only on migrated pre-v5 keys (it cannot be granted to new keys). | Resource | Actions | | ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | | `payments` | `tap-ios`, `tap-android`, `read`, `refund`, `tipadjust`, `capture`, `incremental-auth`, `void`, `confirm` | | `batches` | `read`, `open`, `edit`, `close` | | `terminals` | `read`, `create`, `edit`, `delete` | | `locations` | `read`, `create`, `edit`, `delete` | | `accounts` | `read`, `create:partner`, `create:merchant`, `edit`, `delete` | | `billing` | `read` | | `credentials` | `read`, `create`, `edit`, `delete` | | `apikeys` | `read`, `create`, `edit`, `delete` (and the `:sub` variants below) | | `webhooks` | `read`, `create`, `edit`, `delete` (Koard [PSP](/docs/appendix/glossary#psp "Company that serves multiple merchants with payment processing.") only) | #### Own account vs. sub-accounts API-key management permissions are split by surface. The base form (`apikeys:read`, `apikeys:create`, `apikeys:edit`, `apikeys:delete`) governs keys on **your own** account. The `:sub` variants (`apikeys:read:sub`, `apikeys:create:sub`, `apikeys:edit:sub`, `apikeys:delete:sub`) govern keys on **descendant** (sub-) accounts. These are distinct grants — holding the self permission never satisfies a sub-account operation, and vice versa. #### Privilege-escalation guard When you create a key using an API key, you may only grant permissions that key holds. Requesting a permission outside your grant is rejected with `403`. The `webhooks:*` grants are available to PSP accounts. ### Visibility: 401 vs 404 When a key lacks a permission, the response distinguishes between "denied" and "invisible": * If your key holds **some** permission on the target resource type but not the specific operation, you get `401`. * If your key holds **zero** permissions on the target resource type (the resource is invisible to you), you get `404` — out-of-scope resources are hidden, never advertised. ### Key Lifecycle | Action | Effect | | --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | | **Create** (`POST`) | Issues the key and returns the plaintext once. Defaults to a long expiry; pass `expires_at` to set your own. | | **Revoke** (`PUT` with `status: "revoked"`) | Disables the key for authentication. **Recoverable** — reinstate with `PUT status: "active"`. | | **Reinstate** (`PUT` with `status: "active"`) | Re-enables a revoked key. Does not work on a deleted key. | | **Delete** (`DELETE`) | Permanent soft-delete. The key can never be reinstated. Idempotent — repeating the call returns the same deleted key. | | **Expire** (`expires_at` passes) | The key stops authenticating automatically. | Permissions are **immutable** after creation — a `PUT` may change `name`, `expires_at`, and `status` only. To change a key's permissions, create a new key and delete the old one. Revoking or deleting a key takes effect immediately: in-flight sessions authenticated with that key are locked out. Use `POST /v5/apikeys` to create a key on your own account, or supply `account_id` to target a descendant account with `apikeys:create:sub`. `expires_at` is optional; provide an ISO 8601 / RFC 3339 date-time. Omission or null on create applies the 100-year default expiry. Omission or null on update leaves the current expiry unchanged. Errors, including 400/401/403/404, use `{error, message, details}`. A 404 also hides an account or key outside your hierarchy. See the [API key management guide](/api-reference/api-key-management) for examples and the [API Keys reference](/api-reference/apikeys) for the full contract. # Response Codes Koard uses standard HTTP status codes. | Code | Meaning | | ----- | ---------------------------------------------------------------------------------------------------------------------------------------- | | `200` | Success | | `201` | Resource created | | `400` | Validation failure — check the response body for details | | `401` | Missing or invalid [API key](/docs/appendix/glossary#api-key "Secret header credential for REST calls; v5 keys use scoped permissions.") | | `403` | Insufficient permissions for this operation | | `404` | Resource not found | | `409` | Conflict — resource already exists or state mismatch | | `423` | Locked — merchant account is blocked and cannot perform this operation | | `429` | Rate limited — slow down and retry | | `500` | Unexpected server error | ## Error Body Format Most errors return a structured envelope with a machine-readable `error` code, a short `message` category, and a human-readable `details` string. Branch on `error` in client code; `details` is always a string (never a list or object): ```json { "error": "validation_error", "message": "Request validation failed", "details": "query: sort_by: Input should be 'name' or 'volume'" } ``` `500` responses never leak internal exception messages or stack traces — the incident is logged server-side and `details` carries only a generic string. ### Envelope Error Codes When an error is returned as the structured envelope, the `error` field is one of: | `error` | HTTP | `message` | | ------------------------- | ---- | ------------------------- | | `validation_error` | 400 | Request validation failed | | `authentication_required` | 401 | Authentication required | | `permission_denied` | 403 | Permission denied | | `not_found` | 404 | Resource not found | | `conflict` | 409 | Resource state conflict | | `rate_limited` | 429 | Rate limit exceeded | | `upstream_error` | 502 | Upstream processor error | | `internal_error` | 500 | Internal server error | ## Batch Close Responses `POST /v1/batches/{batch_id}/close` returns the resulting **Batch object** when the processor reports a duplicate (HTTP 409) or rejects settlement (HTTP 502), as well as on acceptance (HTTP 200). Inspect the batch's `response` and `rejection_reason`. Those responses do not use the error envelope above. See [Running Batches](/docs/batch-and-settlements/running-batches). A timeout or server error during a payment operation does not establish that the processor declined it. Use [outcome recovery](/docs/payments/idempotency) before submitting again.