> ## Documentation Index
> Fetch the complete documentation index at: https://docs.payra.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Payra for WooCommerce

> Accept card and US bank account (ACH) payments at your WooCommerce checkout, on the classic checkout and the Checkout block.

Payra for WooCommerce adds up to three payment options to your checkout, on both the classic
`[woocommerce_checkout]` shortcode and the Checkout block: **card** and **US bank account (ACH)** fields
inside your checkout, and **Pay with Payra**, which sends the customer to a [hosted checkout](/hosted-checkout)
page and back. Card and bank details are entered in secure fields rendered by [Payra Elements](/elements)
and tokenized before they reach Payra; your store never sees a card or account number. A bank debit carries the account holder's
ACH authorization, shown by Payra Elements next to the secure fields and accepted there.

Orders are charged through the API from your server with your secret key, refunds are issued from the
order screen, and signed [webhooks](/webhooks) keep the order in step with the payment. Cards are
charged in **USD or CAD** and bank accounts in **USD**, following the store currency; each option
hides itself on any other currency. A **Test mode** switch runs the whole flow on your sandbox keys,
so you can try the checkout without moving money.

## Requirements

* WordPress 6.5 or newer, WooCommerce 8.3 or newer (HPOS and Checkout block compatible), PHP 8.1
  or newer. The plugin is tested up to WooCommerce 11.1.
* A site served over **https** on a publicly reachable host. Payra only delivers webhooks to an `https`
  URL on a public host, and the plugin only accepts an `https://` API host.
* A Payra account with the API enabled, and a workspace to create keys in. Sandbox and live are
  separate workspaces with separate keys; see [Environments](/environments).
* For bank accounts, an ACH processor on that workspace. Without one the bank option cannot charge
  (`payment_provider_not_configured`).

## Install

The plugin is provided by Payra as a zip file.

<Steps>
  <Step title="Upload the plugin">
    In WordPress, open **Plugins → Add New Plugin → Upload Plugin**, choose the zip and activate it.
    WooCommerce must be installed and active first; WordPress will not activate the plugin without it.
  </Step>

  <Step title="Enable the gateway">
    Open **WooCommerce → Settings → Payments**. Payra appears as one entry; open it with
    **Manage** (**Finish set up** on the older Payments table, until it is enabled). All three payment options are set up on that page. The plugin also
    adds a **Settings** link on the Plugins screen that opens the same page.
  </Step>
</Steps>

## Configure

The settings page has four parts: the keys every option shares, the card option, the bank account
option, and the Payra checkout page.

| Field                         | What it takes                                                                                                                                                                                        |
| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Enable/Disable**            | *Enable Payra at checkout*. Off by default.                                                                                                                                                          |
| **Test mode**                 | *Use the sandbox keys (no real money moves)*. On by default.                                                                                                                                         |
| **Sandbox publishable key**   | `pk_test_…`                                                                                                                                                                                          |
| **Sandbox secret key**        | `sk_test_…`                                                                                                                                                                                          |
| **Sandbox webhook secret**    | `whsec_…`, from the endpoint registered on your sandbox workspace.                                                                                                                                   |
| **Live publishable key**      | `pk_live_…`                                                                                                                                                                                          |
| **Live secret key**           | `sk_live_…`                                                                                                                                                                                          |
| **Live webhook secret**       | `whsec_…`, from the endpoint registered on your live workspace.                                                                                                                                      |
| **Cards**                     | *Accept cards in your checkout*. On by default.                                                                                                                                                      |
| **Card title**                | What the customer sees as the card option at checkout. Default: *Credit or debit card*.                                                                                                              |
| **Card description**          | Shown above the card fields at checkout. Default: *Pay securely with your card.*                                                                                                                     |
| **Bank accounts**             | *Accept bank account (ACH) payments*. Offers the bank option at checkout. Off by default.                                                                                                            |
| **Bank account title**        | What the customer sees as the bank option. Default: *Bank account (ACH)*.                                                                                                                            |
| **Bank account description**  | Shown above the bank fields. Default: *Pay from your US bank account. Your order is confirmed once your bank debit is processed.*                                                                    |
| **Checkout page**             | *Offer the Payra checkout page*. Off by default.                                                                                                                                                     |
| **On the page**               | *Also accept bank accounts (ACH)*. Cards are always accepted on the page; bank accounts need a USD order and an ACH processor on your workspace. Off by default.                                     |
| **Checkout page title**       | What the customer sees as this option. Default: *Pay with Payra*.                                                                                                                                    |
| **Checkout page description** | Shown under the option. Left empty, it says what the page accepts and that the customer comes back to the store: *Pay by card or bank account on a secure Payra page, then come back to this store.* |

An **Advanced → API host** field appears only on a store Payra pointed at another host; leave it alone unless Payra tells you otherwise.

Each API key field only accepts a key of its own kind (`pk_test_`, `sk_test_`, `pk_live_`, `sk_live_`): a
value with the wrong prefix is rejected with *must start with …* and the previous value is kept, so a live
key cannot end up behind **Test mode**. The two webhook secret fields accept any `whsec_` value, so check
that the sandbox one holds the sandbox endpoint's secret (`whsec_test_…`) and the live one the live
endpoint's (`whsec_live_…`). With **Test mode** on, the sandbox keys are used; off, the live keys. Each option is only
offered at checkout when it is switched on and both keys of the active mode are set, and the Payra
checkout page only on a store served over `https`. The options are independent and each is its own
entry at checkout. Offering the checkout page next to the card fields gives the customer two ways to pay
by card, so most stores pick one: the fields in the checkout, or the page.

<Steps>
  <Step title="Create the keys">
    In the Payra dashboard, open **Settings → Integrations → API keys** and create a publishable key
    and a secret key in the workspace you are connecting, keeping the **Payments** and **Payment
    methods** scopes on the secret key (every scope is selected by default). Paste them in the fields
    of the matching mode. Keep **Test mode** on while you try the checkout with sandbox keys.
  </Step>

  <Step title="Register the webhook">
    In **Settings → Integrations → Webhooks**, add an endpoint pointing at your store's webhook URL
    (see below) and paste the `whsec_` secret it gives you in **Sandbox webhook secret** or **Live
    webhook secret**, depending on the workspace you registered it on. The settings page prints the
    URL of your store next to both fields.
  </Step>

  <Step title="Go live">
    Repeat both steps on the live workspace, paste the live keys and the live webhook secret, and
    turn **Test mode** off.
  </Step>
</Steps>

<Warning>
  Deleting the plugin removes its settings, including the stored secret keys. Deactivating it does
  not.
</Warning>

## Webhooks

The plugin exposes one webhook endpoint:

```text theme={null}
https://your-store.example/wp-json/payra/v1/webhook
```

Register it on each workspace you use (sandbox and live), and keep every event selected (the default
in **Add webhook endpoint**, sent as `["*"]`: every event, including types added later), or at least
every `payment.`, `refund.` and `checkout_session.` type. The plugin acts on `payment.*` events by
moving the order, settles an order paid on the Payra checkout page on `checkout_session.completed`, and
records `refund.*` events as order notes.

<Warning>
  If you offer bank accounts, the webhook is required: a bank debit settles in the processor's next
  batch after checkout, and `payment.succeeded` is what moves the order from **On hold** to paid. Without the
  webhook, bank orders stay on hold.
</Warning>

When a delivery arrives, the plugin:

* **Verifies the signature** against the secret of the event's lane: `livemode: false` is checked
  with the sandbox webhook secret, `livemode: true` with the live one. A missing secret or a bad
  signature answers `400` and changes nothing. A delivery signed more than five minutes ago is
  refused.
* **Refuses a lane mismatch.** The ids in the event's object (`pay_test_…` / `pay_live_…`,
  `re_test_…` / `re_live_…`) must agree with the event's `livemode`; otherwise it answers `400`
  `lane_mismatch`. A sandbox secret can never move an order paid live.
* **Finds the order** by the `pay_` id stored at the charge (a `refund.*` event carries its
  payment's id), or by the `cs_` id for a `checkout_session.*` event. A payment the store did not make
  is acknowledged with `200` so Payra stops retrying, and nothing changes; so is a `payment.failed` for
  an order sent to the checkout page, where the customer can try again, or for an order already paid.
* **Deduplicates on the event id.** Each event id is remembered on the order it touched (the last
  50\); a retry or a resend of the same event is acknowledged with `200` and ignored.

For a `payment.*` event, the plugin asks the API for the payment's **current** status
(`GET /payments/{id}`) before moving the order, since deliveries can arrive out of order; the
event's own snapshot is used only when that read does not succeed.

<Note>
  A `payment.*` event for a payment the store asked for but never recorded (the API answered after
  the plugin's timeout, or the delivery beat the store) is matched by the order number sent as
  `reference`, only when that number is also the order's id (WooCommerce's default numbering) and the
  order is a Payra order for exactly that amount and currency. The payment id is then stored on the order so the money is accounted for. If the order was already
  paid by another payment, an order note names both ids and asks you to refund one of them in Payra.
</Note>

## How a payment flows

1. When the customer picks an option, the browser asks your store for a [payment method
   session](/payment-method-sessions): `card`, or `us_bank_account` with the order total as the
   `amount` the account holder will authorize. The store creates it with the secret key and hands
   back the `client_secret`; the secret key never leaves PHP. Session requests are tied to a checkout
   in progress (a cart, or an order being paid) and limited to 30 per IP address, counted until 10
   minutes pass without a new session.
2. Payra Elements mounts the secure fields of that option. For a bank account: routing and account
   number, account type (checking or savings), account holder type (individual or company), and the
   ACH authorization naming your business and the order total, which the customer must tick. If the total changes after the fields load (a coupon,
   shipping), a new session is created for the new total. On **Place order**, the session is
   confirmed in the browser with the customer's name and billing postal code, and its id is sent to
   the store.
3. The store turns the session into a payment method and charges it (`POST /payments`) for the
   order total, with the order number as `reference` and *Store name order #123* as
   `description`.
4. The order moves with the payment's status, at the charge and again on every `payment.*` event:

| Payment status                                | Order                                                                                                                                                                                                                            |
| --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `succeeded`                                   | Paid (`payment_complete`), when the order is `pending`, `failed` or `on-hold`. A late `succeeded` never reopens an order you already refunded or cancelled: an order note says the money was captured and asks you to review it. |
| `processing`, `authorized`, `requires_action` | **On hold**, with a note that the plugin is waiting for Payra to confirm. A bank debit is always `processing` at checkout, so a bank order starts on hold and moves to paid on `payment.succeeded`.                              |
| `failed`                                      | **Failed**.                                                                                                                                                                                                                      |
| `returned`                                    | **Failed**, with a note naming a bank return or chargeback.                                                                                                                                                                      |
| `canceled`                                    | **Cancelled**, with a note that the payment was voided.                                                                                                                                                                          |

For a bank order the thank-you page says: *Thank you. Your bank debit is on its way, and we will email
you once your payment is confirmed.*

**Declines.** A `402` from the charge (`POST /payments`) fails the order and tells the buyer why, in
a sentence written by the plugin for the decline code (for a card: `insufficient_funds`, `invalid_payment_method`,
`processing_error`, or a generic *Your card was declined*; for a bank account: *The bank account
details were not accepted*, *Your payment could not be processed right now*, or *The debit was
refused*). The decline code and the payment id go in an
order note. A card or bank account Payra cannot turn into a payment method fails the order with *We
could not verify your card. Please try again.* (*We could not use this bank account. Please check the
details or try another account.*) and the API's detail in an order note. If a bank order's total no longer matches the amount the customer authorized, the charge
is refused and the customer is asked to enter the bank details again. A refused charge spends the session: the checkout mounts a fresh one for the next
attempt. Any other error fails the order with a generic message for the buyer and the API's detail
in an order note.

**Idempotency.** The charge is keyed per store, order and payment method, and each confirmed session
makes one payment method: the same session submitted twice replays one charge (or one decline), while
a new session, even with the same card, is a new key. A
transport failure is retried once under the same key, so a charge the API completed without the
store hearing it comes back instead of being made again.

<Warning>
  If the API does not answer the charge at all, even on the retry, the order is set to **Failed**
  with the note *Payra did not answer the charge (\<error>). Its outcome is unknown: check Payra
  for a payment with reference \<order number> before charging again.* The buyer is asked to contact the
  store before trying again. Look the reference up in Payra before charging the customer a second
  time; if the payment did go through, the `payment.succeeded` webhook also matches the order by
  that reference (when the order number is the order's id).
</Warning>

### Payra checkout page

**Pay with Payra** shows no fields, and its button reads **Continue to payment**. It creates a
[checkout session](/hosted-checkout) for the order total (`reference` is the order number, `success_url`
the order confirmation, `cancel_url` the checkout, or the order's payment page when the customer is paying
an existing order) and sends the customer to its `url`; an order note keeps the `cs_…` id. The order stays
**Pending payment** until the payment is confirmed, by whichever arrives first: the
`checkout_session.completed` webhook (found by the session, whatever your order numbers look like), the
`payment.*` webhook (found by the order number), or the customer's return, when the order confirmation page
reads the session itself. The order then moves as in the table above; a bank debit paid on the page puts
it **On hold**. The payment is only applied when it paid exactly the order's total; otherwise the order is
left as it is with a note to review it.

A declined card on the page leaves the order pending while the customer tries again there. The page is
closed when the customer places the order again (the checkout reuses the same pending order while the
cart is unchanged, and a new page opens), when the order is paid with the card or bank fields in the
checkout instead, and when the order is cancelled, by you or by WooCommerce once its unpaid hold runs
out. An abandoned page expires after 24 hours.

## Refunds

The gateway declares refund support, so WooCommerce's refund controls on the order screen offer to
refund through Payra. A refund issued that way is sent to `POST /refunds` for the payment stored on
the order, with the amount entered and the reason, truncated to 200 characters. **Partial refunds** are allowed until the payment is refunded in full, one at a time: while a refund is `pending`, another is refused (`refund_in_flight`). On success the
order gets the note *Refunded … through Payra (refund re\_…, status)*; when the API refuses, the
refund is rejected in WooCommerce with Payra's error message and code, and no refund record is
kept.

**Refund after the payment settles.** A card order refunded right after the charge is usually
refused by the processor until the charge settles: WooCommerce shows *Payra: the processor refused
this refund (…). A card payment can usually be refunded once it has settled, the next business day;
try again then.* Payra keeps that attempt as a `failed` refund and sends `refund.failed`. A bank
order still on hold is refused before reaching the processor: *Payra: this bank debit has not
settled yet. Refund it once the order is marked as paid.*

The refund is keyed on the store, the order, the amount, the number of refunds already on the order
and the number of refunds Payra has refused on it, and retried once on a transport failure: a retry
after a timeout replays the refund the API already made instead of making a second one, while an
attempt after a refusal is a new request that reaches the processor again.

An order with no Payra payment id stored on it cannot be refunded through Payra: *This order has no
Payra payment to refund.*

<Note>
  A refund made in the Payra dashboard on a payment made by the plugin is announced by a
  `refund.*` event. The plugin records it as an order note (*Payra refund.succeeded: refund re\_…
  for \$25.00*) but does not create a WooCommerce refund record or change the order status. Record
  it in WooCommerce yourself if you need the order to reflect it.
</Note>

## Pay-for-order page

Payra also works on the pay-for-order page (an existing order paid from **My account**). There, the
order stands in for the cart and the billing fields: its currency decides which options are offered,
its total is the amount a bank account authorizes, and its billing name and postal code are sent with
the card or bank account. The order only stands in for the cart when the page carries the order's key
(the `key` query parameter WooCommerce puts in the link) and the order still needs payment; otherwise
the plugin reads nothing from the order.

## Troubleshooting

**The fields say *Loading secure card fields…* (or *Loading secure bank account fields…*) and never
load, or show *Card payments are temporarily unavailable* (or *Bank account payments are temporarily
unavailable*).** The plugin loads Payra Elements from the API host (`<API host>/v1/payra.js`). The
buyer sees that message when the script cannot load, when the publishable key is badly formatted, or
when the store cannot create a session with the secret key. A publishable key that looks right but
that Payra refuses shows Payra's own error under the fields instead. Check that both keys of the
active mode are correct and that your server can reach the API host; a failed session creation is
logged under the `payra` source in **WooCommerce → Status → Logs**. A visitor over the session limit
sees *Too many attempts. Please wait a few minutes and try again.* on the Checkout block, and the
*temporarily unavailable* message on the classic checkout.

**An option is not offered at checkout.** Each option hides itself when it is switched off (**Enable
Payra** for cards, **Bank accounts** for bank accounts), when either key of the active mode is empty,
or when the currency is not one it charges (USD or CAD for cards, USD for bank accounts). On the
pay-for-order page the order's currency is what counts, not the store's.

**A bank order stays on hold.** That is normal until the debit settles in the processor's next batch.
If it never moves, check that the webhook is registered and its secret pasted (see
[Webhooks](#webhooks)); the order moves on `payment.succeeded`.

**A *Test mode* line appears above the fields.** That is expected while **Test mode** is on. Above
the card fields: *Test mode: use a test card such as 4111 1111 1111 1111 with any future expiration
date and any security code.* Above the bank fields: *Test mode: use a test routing number such as
021000021 and any account number of 4 to 17 digits.* Turn **Test mode** off once the live keys are in
place.

**A webhook answers `400`.** `invalid_signature` means the secret of that lane is missing or does
not match the endpoint that sent the event, or the signature is more than five minutes old; `lane_mismatch` means the event's `livemode` and its
object's ids disagree; `not_an_event` means the body is not a Payra event. `503
gateway_unavailable` means WooCommerce is loaded but the Payra gateway is not. Without WooCommerce
active the route does not exist, and WordPress answers `404 rest_no_route`.
