# Migrating to One API

> Map a redirect payin and refunds integration from the Payments API to One API: what changes, what does not, and the order to make the changes in.

Source: https://hub.ozow.com/integration-methods/apis/deprecated-integrations/migrating-to-one-api/

This guide maps a redirect payin and refunds integration from the Payments API to One API. It covers
what changes, what does not, and the order to make the changes in.

One API is the current integration path. The Payments API continues to process live traffic and is
not being switched off on a fixed date, but it does not receive new features, new payment methods
are added to One API first.

## Before you start

- You have a working Payments API integration using [Redirect: Payments API](https://hub.ozow.com/integration-methods/apis/deprecated-integrations/redirect-to-ozow.md),
  [Refunds: Payments API](https://hub.ozow.com/integration-methods/apis/deprecated-integrations/refund-a-payment.md), or both
- You have a Client ID and Client Secret from the One API Clients section of your [Ozow Dashboard](https://dash.ozow.com/MerchantAdmin/OneAPI/Clients)
- You can run a staging integration against One API before touching production

> ℹ️ **Note**: Only users with administrator privileges in the Ozow Dashboard can access the One API
> Clients section.

## What does not change

- Your site code stays the same
- The payment methods available to your customers, Pay by Bank, Capitec Pay, Buy Now Pay Later,
  PayShap Request, are the same products on both APIs
- Your float, settlement schedule, and Ozow Dashboard reporting are shared across both APIs
- ZAR is the only supported currency on either API

## What changes

| Concept | Payments API | One API |
|---|---|---|
| Authentication | API key plus a SHA512 hash signed with your private key, per request | OAuth 2.0 Client Credentials, a bearer token from `/v1/token` |
| Request shape | Flat form fields, mostly `PascalCase` in notifications, `camelCase` in requests | Nested JSON resources, `camelCase` throughout |
| Creating a payment | `POST /postpaymentrequest` returns a `url` directly | `POST /v1/payments` returns a `Payment` resource with a `redirectUrl` and a set of `links` |
| Payment outcome | The payment request and its outcome are one resource, reported by notification | A `Payment` is the checkout session; each attempt against it is a separate `Transaction`, retrieved via `GET /payments/{id}/transactions` |
| Cancelling before completion | Not possible via API, only the customer can abandon the payment page | `POST /payments/{id}/cancel` |
| Outcome delivery | A form-encoded POST to `notifyUrl`, authenticated with a hash you verify yourself | A signed webhook delivered via [Svix](https://www.svix.com/), authenticated with `svix-id`, `svix-timestamp`, and `svix-signature` |
| Refunds | Submitted as a batch array to `/secure/refunds/submit`, one hash check per item | `POST /transactions/{id}/refunds` for a single transaction, or `POST /refunds` for a batch, both idempotent via an `Idempotency-Key` header |
| Cancelling a refund | Not possible via API | `POST /refunds/{id}/cancel` |
| Standalone payment method button | `selectedBankId` field in the payment request | `institutionId` field in the payment request |
| Customer Identity Verification | Flat `customerIdentifier` field | Nested `payer.identity` object with `type`, `country`, and `identifier` |
| Retrying a failed request safely | Not supported, a resubmitted request is a new payment request | `Idempotency-Key` header on `POST` and `PUT` requests: replaying the same key returns the original result instead of creating a duplicate |
| Recurring payments | Not available | Available, see [Recurring payments: One API](https://hub.ozow.com/integration-methods/apis/recurring-payments/set-up-recurring-payments.md) |
| Embedded checkout | Not available, redirect only | Available: [iframe](https://hub.ozow.com/integration-methods/apis/payin/embedded-iframe.md), [modal](https://hub.ozow.com/integration-methods/apis/payin/embedded-modal.md), and [Wallet SDK](https://hub.ozow.com/integration-methods/apis/payin/embedded-wallet.md) |

---

## Step 1: Replace hash-based authentication with OAuth

The Payments API signs every request with a SHA512 hash built from your private key. One API instead
issues a short-lived bearer token from your Client ID and Client Secret.

Remove your hash-generation code entirely, there is no request hash in One API. Replace it with a
token request:

```endpoint
POST https://one.ozow.com/v1/token
Content-Type: application/x-www-form-urlencoded
```

```bash
curl -X POST "https://one.ozow.com/v1/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "client_id=YOUR_CLIENT_ID" \
  -d "client_secret=YOUR_CLIENT_SECRET" \
  -d "scope=payments" \
  -d "grant_type=client_credentials"
```

A token carries the scopes you asked for and no others, and each scope is refused on the endpoints
outside it. Request the scope the calling code needs rather than one token for the whole
integration:

| What the code is doing | Scope | Where |
|---|---|---|
| Creating a payment, cancelling it, reading its transactions | `payments` | Steps 2, 3 and 5 |
| Managing the webhook endpoint and reading its signing secret | `webhooks` | Step 4 |
| Issuing and cancelling refunds | `refunds` | Step 6 |

A `payments` token is refused with `403 Forbidden` on the webhook and refund endpoints, so the three
parts of your integration hold three tokens. Cache them separately. Asking for a scope your client
has not been granted is refused at the token request itself.

Tokens expire after the number of seconds in `expires_in`, which is 14400, four hours. Read
that field rather than hard coding the number: a change to the lifetime reaches you in the
response before it reaches this page. Cache the token and request a new one before it expires,
rather than requesting one per payment. See [Step 1: Obtain an access
token](https://hub.ozow.com/integration-methods/apis/payin/redirect-to-ozow.md#step-1-obtain-an-access-token) for the full flow and code
samples in C#, PHP, JavaScript, and Python.

> ⚠️ **Important**: Your private key has no equivalent in One API and is not used for anything. Do
> not send it, and retire it from your secrets store once the migration is complete and the Payments
> API integration is decommissioned.

---

## Step 2: Move from flat fields to nested resources

The Payments API request is a single flat object. One API groups related fields, most visibly
`amount` becomes an object with `currency` and `value`.

**Payments API**

```json
{
  "siteCode": "YOUR_SITE_CODE",
  "countryCode": "ZA",
  "currencyCode": "ZAR",
  "amount": "100.00",
  "transactionReference": "ORDER-001",
  "bankReference": "ABC123",
  "cancelUrl": "https://yourstore.com/cancel",
  "errorUrl": "https://yourstore.com/error",
  "successUrl": "https://yourstore.com/success",
  "notifyUrl": "https://yourstore.com/notify",
  "isTest": false,
  "hashCheck": "YOUR_GENERATED_HASH"
}
```

**One API**

```json
{
  "siteCode": "YOUR_SITE_CODE",
  "amount": {
    "currency": "ZAR",
    "value": 100.00
  },
  "merchantReference": "ORDER-001",
  "beneficiaryReference": "ABC123",
  "expireAt": "2026-12-31T23:59:59Z",
  "returnUrl": "https://yourstore.com/order-complete"
}
```

**Field mapping**

| Payments API | One API | Notes |
|---|---|---|
| `siteCode` | `siteCode` | Unchanged |
| `countryCode` | `region` | Same ISO 3166-alpha-2 code, renamed and moved to the top level. Optional on One API, and defaults to `ZA` when you omit it |
| `currencyCode` | `amount.currency` | Moves inside `amount`, and must be `ZAR` |
| `amount` (string) | `amount.value` (number) | One API takes a numeric value, not a pre-formatted string |
| `transactionReference` | `merchantReference` | Same purpose, renamed. Still the reference the payer sees on their own statement, and still yours to keep unique |
| `bankReference` | `beneficiaryReference` | Same purpose, renamed: the reference that appears on **your** bank statement, for recon. One API is stricter, letters and numbers only where the Payments API also allowed spaces, dashes and punctuation |
| `cancelUrl`, `errorUrl`, `successUrl` | `returnUrl` | One API redirects to a single `returnUrl` regardless of outcome; determine the outcome from a verified webhook or the transactions endpoint, not from which URL fired |
| `notifyUrl` | `notifyUrl` | Still accepted, still optional, and still per payment request. Move to webhooks instead: they are configured once and signed, and Step 4 is that change |
| `expiryDateUtc` | `expireAt` | **Now required on every payment request**, where the Payments API let you omit it. ISO 8601 (`2026-12-31T23:59:59Z`) rather than `yyyy-MM-dd HH:mm` |
| `isTest` | Not a request field | Test and live are separated by environment (`stagingone.ozow.com` vs `one.ozow.com`), not by a flag on the request |
| `hashCheck` | Not a request field | Removed entirely, see Step 1 |

For the full request and response schema see [Create a payment](https://hub.ozow.com/api-reference/one-api/post-payments.md).

---

## Step 3: Update how you read the response and redirect the customer

**Payments API response**

```json
{
  "paymentRequestId": "00000000-0000-0000-0000-000000000000",
  "url": "https://pay.ozow.com/00000000-0000-0000-0000-000000000000/Secure",
  "errorMessage": null
}
```

**One API response**

```json
{
  "links": {
    "self": "https://one.ozow.com/v1/payments/497f6eca-6276-4993-bfeb-53cbbbba6f08",
    "cancel": "https://one.ozow.com/v1/payments/497f6eca-6276-4993-bfeb-53cbbbba6f08/cancel",
    "transactions": "https://one.ozow.com/v1/payments/497f6eca-6276-4993-bfeb-53cbbbba6f08/transactions"
  },
  "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
  "status": "Created",
  "redirectUrl": "https://pay.ozow.com/497f6eca-6276-4993-bfeb-53cbbbba6f08/secure"
}
```

Redirect the customer to `redirectUrl` instead of `url`. Store `id` instead of `paymentRequestId`,
you use it to check transactions or cancel the payment later.

> ⚠️ **Important**: Persist that `id` against your order, before you redirect. On One API the `id` is
> the handle to the payment: [Get a payment](https://hub.ozow.com/api-reference/one-api/get-payments-id.md), [List its
> transactions](https://hub.ozow.com/api-reference/one-api/get-payments-id-transactions.md) and [Cancel
> it](https://hub.ozow.com/api-reference/one-api/post-payments-id-cancel.md) all key off it, where the Payments API keyed off
> the reference you chose. Store it with the order rather than for the life of the request.

> ⚠️ **Important**: The Payments API returns a rejected request as HTTP 200 with a `null` `url` and
> a reason in `errorMessage`. One API uses HTTP status codes for this instead, a validation failure
> comes back as `400 Bad Request` with an `Error` body. Update your error handling to check the
> status code rather than inspecting the response body for a `null` URL.

A `Payment` created but never completed by the customer settles into one of two `PaymentStatus`
values, `Created` or `Expired`. This is a separate concept from the transaction outcome, see Step 5.

---

## Step 4: Move from a per-request notifyUrl to a webhook subscription

The Payments API takes a `notifyUrl` on every payment request and posts a form-encoded notification
to it. One API configures a webhook endpoint once, and delivers signed events to it for every
subsequent payment.

**Set up your webhook endpoint** in one of two ways:

- **Via the Ozow Dashboard**: navigate to [One API
  Clients](https://dash.ozow.com/MerchantAdmin/OneAPI/Clients), select your client, and manage
  webhooks from there
- **Via the API**: see the [webhook endpoints](https://hub.ozow.com/api-reference/one-api/tags/webhooks.md). These need a token
  with the `webhooks` scope, and so does [Get Webhook
  Secret](https://hub.ozow.com/api-reference/one-api/get-webhooks-id-secret.md) below. Your payin token does not reach them

**Subscribe to [`transaction.complete`](https://hub.ozow.com/api-reference/one-api/webhooks/transaction-complete.md)** to receive
the same outcome your Payments API `notifyUrl` delivers. Choose the `full` message type if you
need transaction details in the payload, or `thin` if you will call the API separately for
details.

Replace your hash verification with signature verification. Every webhook carries `svix-id`,
`svix-timestamp` and `svix-signature` headers, and
[Verify a webhook signature](https://hub.ozow.com/integration-methods/apis/payin/verify-a-webhook.md) has the five steps and a
working verifier in four languages. Retrieve your webhook secret with
[Get Webhook Secret](https://hub.ozow.com/api-reference/one-api/get-webhooks-id-secret.md).

> ⚠️ **Important**: Never process a webhook notification without first verifying its signature, in
> exactly the way you never processed a Payments API notification without first verifying its hash.
> Log and alert on verification failures.

See [Step 4: Handle the webhook
notification](https://hub.ozow.com/integration-methods/apis/payin/redirect-to-ozow.md#step-4-handle-the-webhook-notification) for the full
webhook setup and verification process.

---

## Step 5: Re-map transaction statuses

The Payments API reports one flat status on the notification. One API separates the `Payment` (the
checkout session) from each `Transaction` attempted against it, and the transaction carries the
outcome you act on.

**Payments API notification statuses**

| Status | Description |
|---|---|
| `Complete` | Payment completed successfully |
| `Cancelled` | Customer cancelled the payment |
| `Error` | An error occurred: check `SubStatus` for detail |
| `Abandoned` | Customer left the payment page without completing |
| `Pending` | The outcome is not known yet and is reposted to your `notifyUrl` once it is. If you do not use a `notifyUrl` you receive `PendingInvestigation` instead |
| `PendingInvestigation` | Payment is under review: do not credit until resolved |

**One API transaction statuses**

| Status | Description |
|---|---|
| `Successful` | The transaction completed, credit the order |
| `Incomplete` | The customer did not complete the payment attempt |
| `Error` | The transaction failed. `reason` carries the detail |
| `Pending` | The transaction is still in progress |
| `Refunded` | The transaction completed and has since been refunded |

These are not the same enumeration and the values do not line up one to one. `Refunded` has no
Payments API equivalent, because a refund was reported as a separate resource entirely.

Audit every status comparison before you cut over. `Error` and `Pending` keep their spelling across
both APIs, but `Complete` becomes `Successful`. An equality check against `Complete` therefore
returns false for every One API transaction without raising an error, leaving paid orders
unfulfilled. Treat `Successful` as the only status that means the order is paid.

> ⚠️ **Important**: Never update an order status without first verifying the webhook signature, the
> same rule as the Payments API notification hash.

---

## Step 6: Rebuild refunds on the new endpoints

If you issue refunds, the request and authentication both change alongside the payin flow.

- Request a token with the `refunds` scope, the same call as Step 1 with a different `scope`. This
  replaces the Payments API's separate `/token` bearer flow for refunds, and it is a different token
  from the one your payin code holds
- Replace the batch array to `/secure/refunds/submit` with either `POST /transactions/{id}/refunds`
  for a single refund or `POST /refunds` for a batch, see [Refunds: One
  API](https://hub.ozow.com/integration-methods/apis/refunds/refund-a-payment.md)
- Add an `Idempotency-Key` header to every refund request, retrying a failed submission with the
  same key returns the original result instead of issuing a second refund
- Drop your per-item hash check, refund requests are authenticated by the bearer token alone
- `refundReason` becomes `reason`, and is still required
- `isRtc` becomes `realTimePayment`
- Refund statuses keep their spelling but shrink from nine values to six: `Pending`, `Submitted`,
  `Complete`, `Failed`, `Cancelled` and `Returned`. `PendingInvestigation`, `Invalid` and `Error`
  do not exist on One API, so any branch you have for those three needs somewhere else to go

---

## Migrating without downtime

Do not attempt a single cutover. Payments API and One API are separate systems, and a payment
created on one is not visible on the other.

1. Build and test the full One API flow in staging: token, payment creation, webhook delivery, and
   refunds if you use them
2. Deploy the One API integration behind a flag or a new code path, without removing the Payments
   API path yet
3. Route new payments to One API while existing in-flight Payments API payments finish on the old flow
4. Keep your Payments API webhook handler live until every payment created before the cutover has
   resolved, a payment can still complete or time out for some time after creation
5. Once no in-flight Payments API payments remain, decommission the old endpoint calls, retire the
   private key, and remove the hash-generation code

> ⚠️ **Important**: Test in the staging environment before switching production traffic. Staging
> credentials and endpoints are entirely separate between the two APIs, a Payments API staging site
> code does not carry over to One API.

---

## What this guide does not cover

If your Payments API integration uses more than payin and refunds, the rest moves too, and each
part has its own guide rather than a step here:

| On the Payments API | Where it goes |
|---|---|
| `/secure/settlements`, `/secure/settlements/getsitesettlements` | [Reconcile settlements](https://hub.ozow.com/integration-methods/apis/settlements/reconcile-settlements.md) |
| `/secure/banktransfer/single`, `/secure/banktransfer/multiple` | [Send a payout](https://hub.ozow.com/integration-methods/apis/payout/send-a-payout.md) |
| `/secure/bulkpaymentrequests/create` | No One API equivalent, contact support before you migrate |

Migrate payin first. The others are separate integrations against separate endpoints, and each can
move on its own schedule.

---

## Next steps

- Review the [Building a secure
  integration](https://hub.ozow.com/getting-started/building-a-secure-integration.md) checklist, the webhook and
  credential handling sections apply to One API's model
- Test your migrated integration using [Payin test cases](https://hub.ozow.com/integration-methods/testing/payin-test-cases-one-api.md)
- See [Redirect: One API](https://hub.ozow.com/integration-methods/apis/payin/redirect-to-ozow.md) and [Refunds: One
  API](https://hub.ozow.com/integration-methods/apis/refunds/refund-a-payment.md) for the complete guides
- Contact [support@ozow.com](mailto:support@ozow.com) if you need your One API Client ID and Client
  Secret issued or your webhook endpoint configured