Paylinq
|API Documentation

Paylinq API Reference

Accept payments in your application with a single REST integration. Paylinq orchestrates multiple payment providers (Stripe, Airwallex, Mollie, PayPal) and automatically routes each transaction to the best available provider using priority ordering, circuit breaking, and round-robin load distribution.

Integration in 3 steps:

  1. 1
    Create a paymentPOST /v1/payments

    Pass amount, currency, and optionally customer details. You get back a payment_url.

  2. 2
    Send your customer to checkoutpayment_url

    Redirect to our hosted pay page or embed it as an iframe on your site.

  3. 3
    Receive a webhookpayment.succeeded

    We POST to your webhook URL when the payment completes. Update your order status.

Base URL

https://api.paylinq.net

Content-Type

application/json

Amounts

Always in smallest unit (cents). 2500 = €25.00

Rate limit

100 requests / minute per API key

Payment Statuses

StatusMeaning
pendingPayment created, awaiting customer interaction
processingCustomer has initiated a payment method; awaiting provider confirmation
requires_action3D-Secure or additional authentication required from the customer
succeededPayment completed successfully — money has been captured
failedAll provider attempts failed
cancelledPayment was cancelled before completion
refundedFull refund issued
partially_refundedPartial refund issued; original amount – refunded_amount remains captured

Authentication

Every API request must include your API key in the X-API-Key header. Retrieve your key from the Apps page of the dashboard. Each app has its own key.

Keep your API key secret. Never expose it in client-side JavaScript, public repositories, or browser requests. All API calls must originate from your server.
curl https://api.paylinq.net/v1/payments \
  -H "X-API-Key: plq_live_YOUR_API_KEY"

Idempotency

The Paylinq API supports idempotent requests on payment creation and refund creation. This lets you safely retry a request after a network failure without the risk of creating duplicate payments or double-debiting funds.

Always use an Idempotency-Key when creating payments or refunds from a server. Without it, a network timeout followed by a retry will create two separate charges.

How it works

Add an Idempotency-Key header containing any unique string (UUID recommended) to your request. If you repeat the same request with the same key within 24 hours, Paylinq returns the original response instead of creating a new resource. After 24 hours the key expires and a new resource can be created with the same key.

Keys are scoped per app — the same key value sent by two different API keys is treated independently.

Creating a payment safely

curl -X POST https://api.paylinq.net/v1/payments \
  -H "X-API-Key: plq_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order_1234_attempt_1" \
  -d '{"amount": 2500, "currency": "EUR", "description": "Order #1234"}'

# Retrying with the same key returns the original payment — no duplicate charge:
curl -X POST https://api.paylinq.net/v1/payments \
  -H "X-API-Key: plq_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: order_1234_attempt_1" \
  -d '{"amount": 2500, "currency": "EUR", "description": "Order #1234"}'

Creating a refund safely

curl -X POST https://api.paylinq.net/v1/refunds \
  -H "X-API-Key: plq_live_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: refund_order_1234_full" \
  -d '{"payment_id": "a1b2c3d4-...", "reason": "Customer request"}'

Key guidelines

Generate per actionUse a fresh UUID per payment or refund. Do not reuse a key for a different amount or a different customer.
Persist the keyStore the key alongside your order before sending the request. On retry, read it back and send the same value.
24-hour windowA key expires 24 hours after the original request. After expiry, the same key may create a new resource.
Scoped per appKeys from different API keys never collide — each app has its own idempotency namespace.
Optional headerIf omitted, the request always creates a new resource. We strongly recommend always including it.

Payments

A payment represents a single transaction. Create one server-side, redirect your customer to the hosted checkout page, and receive a webhook when it completes. All amounts are in the smallest currency unit (cents).

Customers

Customer records store contact details and link to all payments made by that person. Paylinq automatically creates and matches customers when you pass customer_email on a payment — you only need these endpoints if you want to manage customers directly.

Recommended: Pass customer_email and customer_name directly on POST /v1/payments. Paylinq will find an existing customer by email or create a new one automatically. You don't need to pre-create customers.

Refunds

Issue full or partial refunds against succeeded payments. Paylinq routes the refund back through the same provider that processed the original payment.

Coupon Codes

Discount codes with percentage or fixed-amount reductions, optional usage limits, minimum amounts, and validity date ranges. Codes can be scoped to one or more products — when scoped, the code only applies to payments that reference one of those products. Customers can enter codes on the hosted checkout page, or you can create and validate them server-side.

Coupons are managed in the Coupons section of the dashboard. Customers can also type a coupon code directly into the hosted checkout page — no extra integration needed.

Settlements

Settlements are weekly consolidated reports generated every Monday. They group all succeeded payments from the prior week, calculate provider fees, and break down volume per provider.

Webhooks

When a payment status changes, Paylinq sends an HTTP POST to the webhook URL you configured for your app. Configure the URL in the Apps section of the dashboard.

Always verify the signature before trusting a webhook payload. Any request without a valid X-Paylinq-Signature must be rejected.

Events

EventWhen it fires
payment.succeededPayment completed — money captured. Safe to fulfil the order.
payment.failedAll provider attempts have failed. Customer should retry or use a different method.
payment.refundedA full or partial refund was issued.
payment.cancelledPayment was explicitly cancelled (status set to cancelled).
payment.requires_actionAwaiting customer action (e.g. 3D-Secure). No fulfilment action needed yet.

HTTP headers sent with every webhook

Content-Typeapplication/json
X-Paylinq-SignatureHMAC-SHA256 hex digest of the raw JSON body, keyed with your app's webhook_secret
X-Paylinq-EventEvent name (e.g. payment.succeeded)

Payload shape

json
{
  "id":      "wh_del_abc123-...",
  "event":   "payment.succeeded",
  "created": "2026-04-15T10:35:00Z",
  "data": {
    "payment_id":      "a1b2c3d4-...",
    "external_ref":    "order_1234",
    "amount":          2500,    // original order amount before any coupon discount (smallest currency unit)
    "discount_amount": 0,       // coupon discount applied (0 if no coupon); amount - discount_amount = charged amount
    "currency":        "EUR",
    "status":          "succeeded"
  }
}

// payment.refunded carries additional fields:
{
  "event": "payment.refunded",
  "data": {
    "payment_id": "a1b2c3d4-...",
    "refund_id":  "ref_abc123-...",
    "amount":     500,
    "currency":   "EUR"
  }
}

Signature verification

Compute HMAC-SHA256 of the raw request body bytes using your app's webhook_secretand compare it to the X-Paylinq-Signature header using a constant-time comparison.

import hmac, hashlib
from flask import Flask, request, abort

app = Flask(__name__)
WEBHOOK_SECRET = "your_webhook_secret"  # From dashboard → Apps

@app.route("/webhooks/paylinq", methods=["POST"])
def handle_webhook():
    body = request.get_data()  # raw bytes — do NOT parse first
    sig  = request.headers.get("X-Paylinq-Signature", "")

    expected = hmac.new(
        WEBHOOK_SECRET.encode(), body, hashlib.sha256
    ).hexdigest()

    if not hmac.compare_digest(expected, sig):
        abort(401, "Invalid signature")

    event = request.json
    match event["event"]:
        case "payment.succeeded":
            payment_id = event["data"]["payment_id"]
            order_ref  = event["data"]["external_ref"]
            # fulfil_order(order_ref)
        case "payment.refunded":
            refund_id = event["data"]["refund_id"]
            # process_refund(refund_id)
        case "payment.failed":
            pass  # notify customer, no action needed

    return {"received": True}, 200
Retry policy: If your endpoint returns a non-2xx status, Paylinq retries with exponential backoff: 1 min → 5 min → 15 min → 1 h → 2 h (5 attempts total). Monitor delivery status in Webhook Logs in the dashboard.

Embed / Widget

Instead of redirecting customers to payment_url, embed the Paylinq checkout directly in your page. The widget communicates status changes back to your page via postMessage.

Option 1 — Script tag (simplest)

Drop this snippet into your HTML. The widget mounts as a full-page overlay:

html
<!-- Step 1: create a payment server-side (POST /v1/payments), get its id -->
<!-- Step 2: embed the widget with that id -->
<script
  src="https://app.paylinq.net/widget.js"
  data-payment-id="PAYMENT_ID"
></script>

Option 2 — iframe with postMessage

html
<iframe
  id="paylinq-checkout"
  src="https://app.paylinq.net/pay/PAYMENT_ID?embed=1"
  width="100%"
  height="620"
  frameborder="0"
  allow="payment"
  style="border:none; border-radius:12px;"
></iframe>

<script>
  window.addEventListener("message", (event) => {
    // Always verify origin in production!
    if (!event.origin.startsWith("https://app.paylinq.net")) return;

    switch (event.data.type) {
      case "paylinq:payment:succeeded":
        // event.data.payload = { paymentId, status }
        document.getElementById("paylinq-checkout").style.display = "none";
        showSuccessPage(event.data.payload.paymentId);
        break;
      case "paylinq:payment:failed":
        showRetryMessage();
        break;
      case "paylinq:payment:requires_action":
        // 3DS or similar — the widget handles it automatically
        break;
    }
  });
</script>

Option 3 — React component

tsx
import { useEffect } from "react";

interface PaylinqCheckoutProps {
  paymentId: string;
  onSuccess?: (data: { paymentId: string; status: string }) => void;
  onFailure?: (data: { paymentId: string; status: string }) => void;
}

export function PaylinqCheckout({ paymentId, onSuccess, onFailure }: PaylinqCheckoutProps) {
  useEffect(() => {
    const handler = (e: MessageEvent) => {
      if (!e.origin.startsWith("https://app.paylinq.net")) return;
      if (e.data.type === "paylinq:payment:succeeded") onSuccess?.(e.data.payload);
      if (e.data.type === "paylinq:payment:failed")    onFailure?.(e.data.payload);
    };
    window.addEventListener("message", handler);
    return () => window.removeEventListener("message", handler);
  }, [onSuccess, onFailure]);

  return (
    <iframe
      src={`https://app.paylinq.net/pay/${paymentId}?embed=1`}
      width="100%"
      height="620"
      frameBorder="0"
      allow="payment"
      style={{ border: "none", borderRadius: 12 }}
    />
  );
}

// Usage:
// const payment = await createPayment({ amount: 2500, currency: "EUR" });
// <PaylinqCheckout
//   paymentId={payment.id}
//   onSuccess={(d) => router.push("/success?id=" + d.paymentId)}
//   onFailure={() => setError("Payment failed. Please try again.")}
// />

postMessage event types

typeWhenpayload
paylinq:payment:succeededPayment completed{ paymentId, status }
paylinq:payment:failedAll attempts failed{ paymentId, status }
paylinq:payment:requires_action3DS in progress{ paymentId }

Error Reference

All error responses have HTTP status ≥ 400 and a JSON body with a detail field:

json
{
  "detail": "Human-readable error message"
}
StatusMeaningWhat to do
400Bad RequestMissing or invalid field in the request body. Check the `detail` message.
401UnauthorizedMissing or incorrect X-API-Key header.
404Not FoundThe ID you referenced (payment, customer, refund) does not exist in your account.
409ConflictDuplicate action (e.g. settlement already generated for the period).
422Processing FailedRequest was valid but the provider rejected it. Check `detail` for the provider error.
429Rate LimitedExceeded 100 requests/minute. Wait and retry with backoff.
500Internal Server ErrorUnexpected error on Paylinq's side. Retry after a short delay. Contact support if it persists.
502Provider ErrorPaylinq could not reach the underlying payment provider. Retry after a short delay.

Retrying failed requests

Safe to retry: 429, 500, 502, and any network error. Use exponential backoff: 1 s, 2 s, 4 s, 8 s… Do not retry 400, 401, or 404 — they are client errors and will not resolve on retry.

Always pass the same Idempotency-Key on every retry attempt. This guarantees that even if your first request reached the server but the response was lost, the retry returns the original result rather than creating a duplicate charge.
import uuid, time, requests

def paylinq_post(path, body, api_key, max_retries=3):
    url     = "https://api.paylinq.net" + path
    idem_key = str(uuid.uuid4())          # generate once, reuse on every retry
    headers = {
        "X-API-Key": api_key,
        "Content-Type": "application/json",
        "Idempotency-Key": idem_key,
    }
    for attempt in range(max_retries):
        r = requests.post(url, headers=headers, json=body)
        if r.status_code < 500:
            return r.json()               # success or client error — don't retry
        wait = 2 ** attempt
        print(f"Server error, retrying in {wait}s...")
        time.sleep(wait)
    r.raise_for_status()