Skip to main content

Direct API Payments

The Direct API lets you collect card details on your own form and submit them to Flowlix for processing. This gives you full control over the payment experience but requires your systems to handle raw card data.
Handling raw card numbers requires PCI DSS compliance. If you prefer to avoid PCI scope, use the Hosted Payment Page instead.

How it works

  1. The customer enters their card details on your checkout page.
  2. Your server sends a POST /v1/payments request with the card details, amount, and currency.
  3. Flowlix returns a 201 Created response immediately. The payment is in pending state and is being processed asynchronously.
  4. Your server polls GET /v1/payments/{id} until the payment reaches a terminal status: succeeded, failed, expired, or — if 3D Secure is required — requires_action (in which case you redirect the customer to next_action.redirect_url and then resume polling).

Create a payment

Send a POST request to /v1/payments with the payment details:
curl -X POST https://api.flowlix.dev/v1/payments \
  -H "Authorization: Bearer fl_test_sk_abc123def456" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order-1234-payment" \
  -d '{
    "amount": 4999,
    "currency": "eur",
    "return_url": "https://shop.example.com/checkout/return",
    "card": {
      "number": "4111111111111111",
      "exp_month": 12,
      "exp_year": 2027,
      "cvc": "314",
      "holder_name": "Jenny Rosen"
    },
    "customer": {
      "email": "jenny@example.com",
      "first_name": "Jenny",
      "last_name": "Rosen",
      "country": "DE",
      "phone": "+491701234567",
      "address": "Kurfuerstendamm 21",
      "city": "Berlin",
      "zip": "10719"
    },
    "description": "Order #1234",
    "metadata": {
      "order_id": "ord_1234",
      "sku": "WIDGET-XL"
    }
  }'

Request parameters

ParameterRequiredDescription
amountYesAmount in minor units (e.g. 4999 = EUR 49.99).
currencyYesThree-letter ISO 4217 code: eur, usd, or gbp.
cardYesCard details object (see below).
card.numberYesThe full card number (digits only, no spaces).
card.exp_monthYesExpiration month (1-12).
card.exp_yearYesExpiration year (4 digits).
card.cvcYesSecurity code (3 digits for Visa/MC, 4 for Amex).
card.holder_nameYesCardholder name as printed on the card.
customerNoCustomer details object. Optional, but if you supply it, every sub-field below is required.
customer.emailConditionalCustomer email for receipts and fraud checks.
customer.first_nameConditionalCustomer first name.
customer.last_nameConditionalCustomer last name.
customer.countryConditionalCountry code (ISO 3166-1 alpha-2, e.g. DE).
customer.phoneConditionalPhone in international format (e.g. +491701234567).
customer.addressConditionalStreet address.
customer.cityConditionalCity.
customer.zipConditionalPostal code.
customer.stateNoState or province (required by some upstream networks for US/CA).
customer.ipNoCustomer IP address. If omitted, Flowlix uses the request remoteAddr — pass the real client IP explicitly when you sit behind a proxy or CDN.
return_urlNoURL the customer is redirected to after a 3D Secure challenge. Required if the card triggers 3DS. Must use https (or http://localhost for local development).
descriptionNoFree-text description for your records. Maximum 500 characters.
metadataNoKey-value pairs for custom data. Maximum 50 keys.

Create response

The create call returns 201 Created immediately. The card brand and expiry have not been resolved yet — those fields are populated by the upstream processor and become available on the next GET /v1/payments/{id}:
{
  "id": "pay_a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "object": "payment",
  "amount": 4999,
  "currency": "eur",
  "status": "pending",
  "description": "Order #1234",
  "card": {
    "brand": null,
    "last4": "1111",
    "exp_month": null,
    "exp_year": null,
    "country": null
  },
  "customer": {
    "email": "jenny@example.com",
    "name": "Jenny Rosen"
  },
  "metadata": {
    "order_id": "ord_1234",
    "sku": "WIDGET-XL"
  },
  "decline_code": null,
  "decline_message": null,
  "redirect_url": null,
  "refunded_at": null,
  "succeeded_at": null,
  "failed_at": null,
  "created": 1719792000,
  "livemode": false,
  "refunded_amount": 0,
  "provider_transaction_id": "1402758057",
  "refunds": [],
  "next_action": null
}
HTTP 201 does not mean the card was charged successfully — it only means Flowlix accepted the payment for processing. The terminal outcome is reported via the status field on subsequent GET calls.

Possible payment statuses

Inspect status on every retrieval and branch on it:
StatusMeaningNext action
pendingStill processing.Keep polling.
requires_actionCustomer must complete 3D Secure.Redirect to next_action.redirect_url, then resume polling after the customer returns to your return_url.
succeededCard was charged successfully.Fulfill the order.
failedCard was declined or upstream processing failed.Read decline_code / decline_message and show the customer an appropriate message.
expiredCustomer did not complete a required action (e.g. 3DS) in time.Treat as failed. The payment cannot be reused — create a new one if the customer wants to retry.
refundedThe payment was previously fully refunded.No action; this is a terminal state.

Handle declines

When a card is declined, the call still returns 201 Created — the declined outcome shows up on the Payment object retrieved via GET /v1/payments/{id} once processing completes. The status becomes failed and the decline reason is reported on the Payment itself, not as an HTTP error envelope:
{
  "id": "pay_a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "status": "failed",
  "amount": 4999,
  "currency": "eur",
  "card": {
    "brand": "visa",
    "last4": "0002",
    "exp_month": 12,
    "exp_year": 2027,
    "country": "US"
  },
  "decline_code": "do_not_honor",
  "decline_message": "The card was declined. Contact your card issuer for details.",
  "succeeded_at": null,
  "failed_at": 1719792003,
  "refunded_amount": 0,
  "provider_transaction_id": "1402758058",
  "refunds": [],
  "next_action": null
}
Use the decline_code to show an appropriate message to the customer:
Decline codeCustomer message
insufficient_funds”Your card has insufficient funds. Please try a different card.”
expired_card”Your card has expired. Please use a different card.”
do_not_honor”Your card was declined. Please contact your bank or try a different card.”
generic_decline”Your card was declined. Please try a different card.”
See the full list of decline codes.

Full integration example

Here is a complete example in Python that creates a payment and polls until it reaches a terminal state:
import time
import uuid

import requests

FLOWLIX_API_KEY = "fl_test_sk_abc123def456"
BASE_URL = "https://api.flowlix.dev"
TERMINAL_STATUSES = {"succeeded", "failed", "expired", "refunded"}


def create_payment(amount, currency, card, customer, *, return_url, description=None, metadata=None):
    """Create a Direct API payment. Returns the Payment object on 201, raises otherwise."""
    payload = {
        "amount": amount,
        "currency": currency,
        "card": card,
        "customer": customer,
        "return_url": return_url,
    }
    if description:
        payload["description"] = description
    if metadata:
        payload["metadata"] = metadata

    response = requests.post(
        f"{BASE_URL}/v1/payments",
        headers={
            "Authorization": f"Bearer {FLOWLIX_API_KEY}",
            "Content-Type": "application/json",
            "Idempotency-Key": str(uuid.uuid4()),
        },
        json=payload,
        timeout=30,
    )
    if response.status_code != 201:
        error = response.json().get("error", {})
        raise RuntimeError(f"Create failed [{error.get('code')}]: {error.get('message')}")
    return response.json()


def get_payment(payment_id):
    response = requests.get(
        f"{BASE_URL}/v1/payments/{payment_id}",
        headers={"Authorization": f"Bearer {FLOWLIX_API_KEY}"},
        timeout=30,
    )
    response.raise_for_status()
    return response.json()


def poll_until_terminal(payment_id, *, max_wait_seconds=120, interval_seconds=2):
    deadline = time.monotonic() + max_wait_seconds
    while time.monotonic() < deadline:
        payment = get_payment(payment_id)
        if payment["status"] in TERMINAL_STATUSES:
            return payment
        if payment["status"] == "requires_action":
            # Hand the redirect URL back to the caller — they need to send
            # the customer through 3DS before polling can continue.
            return payment
        time.sleep(interval_seconds)
    raise TimeoutError(f"Payment {payment_id} did not reach a terminal status in time")


# Usage
payment = create_payment(
    amount=4999,
    currency="eur",
    card={
        "number": "4111111111111111",
        "exp_month": 12,
        "exp_year": 2027,
        "cvc": "314",
        "holder_name": "Jenny Rosen",
    },
    customer={
        "email": "jenny@example.com",
        "first_name": "Jenny",
        "last_name": "Rosen",
        "country": "DE",
        "phone": "+491701234567",
        "address": "Kurfuerstendamm 21",
        "city": "Berlin",
        "zip": "10719",
    },
    return_url="https://shop.example.com/checkout/return",
    description="Order #1234",
    metadata={"order_id": "ord_1234"},
)
print(f"Created {payment['id']} (status={payment['status']})")

final = poll_until_terminal(payment["id"])
if final["status"] == "succeeded":
    print(f"Payment succeeded at {final['succeeded_at']}")
elif final["status"] == "requires_action":
    print(f"Redirect customer to {final['next_action']['redirect_url']}")
elif final["status"] == "failed":
    print(f"Payment failed: {final.get('decline_code')} -- {final.get('decline_message')}")
else:
    print(f"Payment ended in status {final['status']}")

Best practices

  • Always include an Idempotency-Key to prevent duplicate charges on retries. Use a random UUID per attempt unless you have a deterministic key tied to the order.
  • Treat 201 as “accepted, in progress” — never as “succeeded”. Always poll for the terminal status.
  • Always pass return_url if there is any chance the card may trigger 3D Secure. Without it, 3DS-required cards cannot complete authentication.
  • Validate card input client-side before submitting (check Luhn, expiry, CVC length) to reduce unnecessary API calls.
  • Store payment.id in your database linked to the order for future retrieval and refunds.
  • Handle all decline codes, plus expired and validation errors. Don’t assume the only failure mode is failed.
  • Never log full card numbers — log only the last4 from the response.
  • Pass customer.ip explicitly when your service sits behind a proxy or CDN; otherwise the upstream fraud engine sees the proxy IP.
  • Use metadata to link payments to your internal records (order IDs, customer IDs, etc.).