# 0def Pay — integration guide

This is the developer handoff for sites that integrate directly with the API
(no plugin): forevite.com, paketo, and anything else added later. WooCommerce
stores should install the plugin instead — see "WooCommerce" at the end.

Base URL: `https://pay.0def.com/v1/`

Conventions:

- REST over HTTPS, JSON in and out.
- Amounts are decimal strings in major units (`"49.90"`), never cents.
- Identifiers are UUIDs. Trailing slashes are optional.
- Lists are paginated: `count`, `next`, `previous`, `results` (25 per page).

**Never post card details to this API.** A body containing a `card` object is
rejected with 422. Card data is only ever collected on the hosted checkout page,
inside the payment provider's own iframe. That is what keeps your site out of
PCI scope, so do not build your own card form against this API.

## 1. What you need from us before you start

| Item | Notes |
| --- | --- |
| Merchant API key | One per site. Shown once when issued or rotated. |
| Registered site domain | Your callback URLs must live on it (subdomains are fine). |
| Route id | Only if you need to pin payments to a specific acquiring account. |
| Test key | Same API, provider sandboxes, no real money. |

Store the key server-side only — it can create charges. Environment variable,
secret manager, anything but the repository or client-side code.

## 2. Authentication

```
Authorization: Token zdp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```

| Response | Meaning |
| --- | --- |
| `401` | Header missing, or the key does not exist. |
| `403` | The merchant account is suspended. |
| `429` | More than 300 requests per minute from this key. |

## 3. The flow

1. Customer confirms an order on your site.
2. Your **server** creates a payment intent and stores the returned `id` on the order.
3. You redirect the browser to the returned `payment_link`.
4. Customer pays on the hosted checkout page and is sent to your `return_url`.
5. We call your `notification_url` whenever the status changes.
6. Your handler re-reads the intent from the API and releases the order if it is paid.

Both step 4 and step 5 must end in the same place: read the status from the API
and act on it. A customer can close the browser before being redirected, and
methods like Klarna or iDEAL settle minutes later, so the webhook is the only
thing guaranteed to arrive.

## 4. Create a payment intent

`POST /v1/transaction/payment_intent/`

Required: `transaction_ref` (unique per merchant — reusing one is rejected),
`client_ref`, `amount`, `amount_currency`, `customer.first_name`,
`customer.last_name`, `customer.email`, `address.country`,
`callback.return_url`.

Optional: `method` (defaults to `card`), `customer.phone`, the rest of
`address`, `callback.cancel_url`, `callback.notification_url`, `session`,
`order_items[]`, `metadata`, `force_route`.

Callback hosts are validated against your registered domain, so a typo there
comes back as a 422 rather than a dead redirect.

Pass `session.ip_address` and `session.user_agent` from the shopper's request.
They default to those of the API call, which is your own server — that hurts the
provider's risk scoring and your authorisation rate.

```bash
curl -X POST https://pay.0def.com/v1/transaction/payment_intent/ \
  -H "Authorization: Token $ZERODEF_PAY_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "transaction_ref": "order-1001",
    "client_ref": "customer-77",
    "amount": "49.90",
    "amount_currency": "EUR",
    "method": "card",
    "customer": {
      "first_name": "Ana",
      "last_name": "Nikolic",
      "email": "ana@example.com",
      "phone": "+381601234567"
    },
    "address": {
      "street": "Knez Mihailova 1",
      "city": "Beograd",
      "zip": "11000",
      "country": "RS"
    },
    "session": {
      "ip_address": "203.0.113.24",
      "user_agent": "Mozilla/5.0 ..."
    },
    "callback": {
      "return_url": "https://your-site.com/checkout/thanks?order=1001",
      "cancel_url": "https://your-site.com/cart",
      "notification_url": "https://your-site.com/pay/notify?intent={payment_intent_id}"
    },
    "order_items": [
      { "sku": "TS-01", "name": "T-shirt", "quantity": 2, "unit_price": "19.95" },
      { "sku": "SH-99", "name": "Shipping", "quantity": 1, "unit_price": "10.00" }
    ],
    "metadata": { "campaign": "spring" }
  }'
```

`201`:

```json
{
  "id": "9b1f2c7a-4d3e-4f18-9a2b-6c5d4e3f2a10",
  "client_ref": "customer-77",
  "payment_link": "https://pay.0def.com/checkout/kPq3..."
}
```

The sum of `order_items` does not have to match `amount`; `amount` is what gets
charged. Send items anyway — they show up on the checkout page and help with
provider risk checks and disputes.

## 5. Read the status

`GET /v1/transaction/payment_intent/{id}/`

The response carries the intent plus a `payment[]` array of collection attempts,
each with `gateway_id`, `fee`, `refunded`, `card`, `failure_code`,
`failure_message` and a `refund[]` array.

Release the order when `status` is `success` **and** `amount` is what you
expect. Comparing the amount is what stops a tampered callback from marking an
expensive order paid with a cheap intent.

| Status | Release order | Meaning |
| --- | --- | --- |
| `pending` | no | Waiting for the customer or the provider. |
| `authorized` | yes | Funds held, capture pending. |
| `success` | yes | Paid. |
| `partial` | no | Less than the full amount collected — review manually. |
| `failed` | no | Declined or errored. |
| `canceled` | no | Customer abandoned the checkout. |
| `expired` | no | The payment link timed out. |
| `refunded`, `partial_refunded` | — | Money returned; adjust your order. |
| `disputed`, `disputed_won`, `disputed_lost`, `disputed_close` | — | Chargeback lifecycle. |

Filters on the list endpoint: `transaction_ref`, `client_ref`, `client_email`,
`domain`, `gateway_id`, `route_id`, `status`, `search`, `page`. Looking an order
up by your own `transaction_ref` is the reliable way to recover if you ever lose
the intent id.

## 6. Handle the notification

We send a **GET** request to `notification_url` on every status change and
expect **HTTP 200**. Anything else is retried 10 times: the first retry after 30
seconds, the rest 60 seconds apart.

These placeholders in the URL are substituted: `{payment_intent_id}`,
`{transaction_id}`, `{transaction_ref}`, `{client_ref}`, `{status}`. With no
placeholders, `payment_intent_id` and `transaction_id` are appended as query
parameters.

Three rules for the handler:

1. **Do not trust the request.** It carries no payment data and is not signed.
   Treat it purely as a trigger and read the status back from the API.
2. **Be idempotent.** The same event can arrive more than once.
3. **Answer 200 only once you have persisted the outcome.** A 500 gets retried,
   which is exactly what you want if your database was briefly unavailable.

Do not put slow work (emails, invoices, ERP calls) inline — queue it, then
answer 200.

### PHP

```php
<?php

function pay_request(string $method, string $path, ?array $body = null): array
{
    $ch = curl_init(getenv('ZERODEF_PAY_BASE').'/v1'.$path);

    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST => $method,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT => 30,
        CURLOPT_HTTPHEADER => [
            'Authorization: Token '.getenv('ZERODEF_PAY_KEY'),
            'Content-Type: application/json',
            'Accept: application/json',
        ],
        CURLOPT_POSTFIELDS => $body === null ? null : json_encode($body),
    ]);

    $response = curl_exec($ch);
    $status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    $decoded = json_decode((string) $response, true) ?: [];

    if ($status >= 400) {
        throw new RuntimeException($decoded['detail'] ?? 'Payment gateway error (HTTP '.$status.')');
    }

    return $decoded;
}

// GET /pay/notify?intent=9b1f2c7a-...
$order = Order::where('pay_intent_id', $_GET['intent'])->firstOrFail();

try {
    $intent = pay_request('GET', '/transaction/payment_intent/'.$order->pay_intent_id.'/');
} catch (Throwable $e) {
    http_response_code(500);   // retried in 60s
    exit;
}

if (in_array($intent['status'], ['success', 'authorized'], true)
    && (float) $intent['amount'] + 0.01 >= (float) $order->total) {
    $order->markPaid($intent['payment'][0]['id'] ?? null);   // safe to call twice
} elseif (in_array($intent['status'], ['failed', 'canceled', 'expired'], true)) {
    $order->markFailed($intent['payment'][0]['failure_message'] ?? $intent['status']);
}

http_response_code(200);
```

### Node

```js
const BASE = process.env.ZERODEF_PAY_BASE + '/v1';

async function pay(method, path, body) {
  const response = await fetch(BASE + path, {
    method,
    headers: {
      Authorization: `Token ${process.env.ZERODEF_PAY_KEY}`,
      'Content-Type': 'application/json',
      Accept: 'application/json',
    },
    body: body ? JSON.stringify(body) : undefined,
  });

  const data = await response.json().catch(() => ({}));

  if (!response.ok) {
    throw new Error(data.detail || `Payment gateway error (HTTP ${response.status})`);
  }

  return data;
}

app.get('/pay/notify', async (req, res) => {
  const order = await Order.findOne({ payIntentId: req.query.intent });

  if (!order) {
    return res.sendStatus(200);   // nothing of ours; stop the retries
  }

  let intent;

  try {
    intent = await pay('GET', `/transaction/payment_intent/${order.payIntentId}/`);
  } catch (e) {
    return res.sendStatus(500);   // retried in 60s
  }

  const paid = ['success', 'authorized'].includes(intent.status);

  if (paid && Number(intent.amount) + 0.01 >= order.total) {
    await order.markPaid(intent.payment?.[0]?.id);
  } else if (['failed', 'canceled', 'expired'].includes(intent.status)) {
    await order.markFailed(intent.payment?.[0]?.failure_message || intent.status);
  }

  res.sendStatus(200);
});
```

## 7. The return page

Your `return_url` is a browser redirect and proves nothing on its own — same
rule as the webhook: read the intent from the API. If it is still `pending`,
show "we are confirming your payment" rather than a failure; the webhook will
finish the job.

Retrying a failed payment means creating a **new** intent with a **new**
`transaction_ref` (`order-1001-2` is a fine convention). The old link cannot be
reused.

## 8. Refunds

`POST /v1/transaction/refund/`

```bash
curl -X POST https://pay.0def.com/v1/transaction/refund/ \
  -H "Authorization: Token $ZERODEF_PAY_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "payment": "2f8c1d9e-...", "amount": "19.95", "reason": "one item returned" }'
```

`payment` is the **payment** id from `payment[0].id`, not the payment intent id.
Omit `amount` to refund everything still refundable. Repeated partial refunds
are allowed up to the captured amount; going over is rejected with 422. Each
refund moves the payment to `partial_refunded` or `refunded` and fires a
notification.

## 9. Errors

| Code | Body | What to do |
| --- | --- | --- |
| `401` / `403` | `{"detail": "..."}` | Fix the key. Do not retry. |
| `404` | `{"detail": "..."}` | Unknown id, or it belongs to another merchant. |
| `422` | `{"message": "...", "errors": {"field": ["..."]}}` | Fix the payload. |
| `429` | `{"message": "Too Many Requests"}` | Back off and retry. |
| `502` | `{"detail": "...", "provider_message": "..."}` | The provider refused to open the payment. Safe to retry with the same `transaction_ref`. |

Timeouts on create are the one case worth care: the intent may exist even though
you never saw the response. Look it up by `transaction_ref` before creating
another one.

## 10. Going live

- [ ] Test key in place, a test payment completes and the order is released.
- [ ] Notification handler answers 200 and is idempotent (fire the same URL twice).
- [ ] Amount comparison in place before releasing an order.
- [ ] `session.ip_address` and `session.user_agent` are the shopper's, not your server's.
- [ ] Failed and expired intents move the order to a failed state, and the customer can retry with a new reference.
- [ ] Refunds tested, including a partial one.
- [ ] Live key stored as a secret; test key removed from the deployed config.
- [ ] Callback URLs point at the production domain over HTTPS.

Nothing else changes between test and live — same API, same checkout, same
webhooks. Only the key.

## WooCommerce

WordPress stores skip all of the above: download the plugin from
`https://pay.0def.com/docs/woocommerce-plugin.zip`, upload it under *Plugins → Add New →
Upload Plugin*, activate it, then enable it and paste the API key under
*WooCommerce → Settings → Payments → 0def Pay*. It handles the intent, the
redirect, the return, the notification and refunds, and works with both the
classic checkout and the Cart/Checkout blocks.
