payload-plugin-bold
v0.1.0-beta.1
Published
Bold payment button integration for Payload CMS
Maintainers
Readme
payload-plugin-bold
Bold payment button integration for Payload CMS. Adds a transactions collection, three endpoints, and headless React components, so a host app only supplies its Bold keys, a pricing function, and a button.
Built for Bold (Colombia). Requires Payload 3.
Install
pnpm add payload-plugin-boldimport { boldPlugin } from 'payload-plugin-bold'
export default buildConfig({
plugins: [
boldPlugin({
identityKey: process.env.BOLD_IDENTITY_KEY!,
secretKey: process.env.BOLD_SECRET_KEY!,
testMode: process.env.NODE_ENV !== 'production',
redirectionUrl: 'https://mytienda.com/resultado',
relatedCollections: ['orders'],
resolveAmount: async ({ input, req }) => {
const order = await req.payload.findByID({
collection: 'orders',
id: input.reference!,
})
return { amount: order.total, description: `Orden ${order.reference}` }
},
statusMapping: [
{
collection: 'orders',
field: 'status',
values: {
APPROVED: 'paid',
REJECTED: 'payment_failed',
VOIDED: 'refunded',
},
},
],
}),
],
})'use client'
import { BoldPayButton } from 'payload-plugin-bold/client'
export const Checkout = ({ orderId }: { orderId: string }) => (
<BoldPayButton
checkout={{ reference: orderId, relationTo: 'orders' }}
className="rounded-md bg-black px-5 py-3 text-white"
>
Pagar con Bold
</BoldPayButton>
)'use client'
import { useBoldReturn } from 'payload-plugin-bold/client'
export const Resultado = () => {
const { status, final, loading } = useBoldReturn()
if (loading) return <p>Confirmando el pago…</p>
return <p>{final && status === 'APPROVED' ? '¡Pago aprobado!' : status}</p>
}Then register https://<your-site>/api/bold/webhooks in the Bold panel under Integraciones → Webhooks.
What the plugin adds
A bold-transactions collection. Every checkout creates a row and every Bold event updates it. All fields are read-only in the admin, and writes are denied through the REST/GraphQL API — only the plugin's own endpoints mutate it.
Three endpoints:
| Endpoint | Purpose |
|---|---|
| POST /api/bold/checkout | Prices the order server-side, creates the transaction, signs it, returns the BoldCheckout config |
| POST /api/bold/webhooks | Verifies x-bold-signature, deduplicates, updates the transaction and the linked document |
| GET /api/bold/status/:orderId | Reconciles against Bold's voucher API and returns a normalized status |
Client exports from payload-plugin-bold/client: BoldPayButton, useBoldCheckout, useBoldReturn, loadBoldScript. They are unstyled and framework-agnostic — the script tag is injected on demand, so there is no Next.js dependency and nothing to add to your layout.
How orders are linked
The plugin never adds a column to your collections. The transaction owns a polymorphic relatedDocument relationship pointing at whatever you list in relatedCollections, and your collections get a virtual join field (boldTransactions by default) so the payments show up on the order's admin page and in its API response.
relatedCollections: ['orders', 'subscriptions'],
joinField: 'boldTransactions',Set joinField: false to skip the join entirely.
Because bold-transactions defaults to authenticated-read, the join field is omitted from unauthenticated API responses. That is intentional. Widen it with collectionOverrides if your storefront needs it.
Configuration
| Option | Type | Default | Description |
|---|---|---|---|
| identityKey * | string | — | Bold identity key (public) |
| secretKey * | string | — | Bold secret key (server-only) |
| testMode | boolean | false | Uses Bold's test environment semantics |
| resolveAmount | function | — | Prices the order on the server. Required unless allowClientAmount |
| allowClientAmount | boolean | false | Trusts a client-supplied amount. See the security note |
| relatedCollections | string[] | [] | Collections a transaction may link to |
| joinField | string \| false | 'boldTransactions' | Name of the virtual join field |
| statusMapping | array | — | Maps Bold statuses onto a field of a linked document |
| onApproved / onRejected / onVoided / onFailed | function | — | Fired once per real status change |
| onStatusChange | function | — | Fired on every real status change |
| redirectionUrl | string | — | Where Bold returns the customer |
| renderMode | 'embedded' \| 'redirect' | 'redirect' | embedded opens Bold in a modal |
| defaultCurrency | 'COP' \| 'USD' | 'COP' | |
| collectionSlug | string | 'bold-transactions' | |
| collectionOverrides | function | — | Transforms the generated collection |
| fields | function | — | Transforms the generated fields |
| generateOrderId | function | — | Custom order id generator |
| access | object | — | Guards the checkout and status endpoints |
| routes | object | — | Overrides endpoint paths |
| webhookSecret | string | derived | Overrides the webhook HMAC secret |
| logs | boolean | false | Logs lifecycle transitions |
| disabled | boolean | false | Registers the collection but no endpoints |
* required
Security notes
Amounts are priced on the server. The client sends a reference, never a price. resolveAmount looks up the real amount and only then is the integrity signature generated. allowClientAmount: true exists for donation-style flows where any amount is acceptable — do not enable it for fixed-price carts, because a signature over a client-supplied number certifies whatever the client chose to send.
The secret key never reaches the browser. Only the identity key and the finished signature are returned to the client. The signature is SHA256(orderId + amount + currency + secretKey), computed in the endpoint.
Webhooks are verified before they are trusted, using HMAC-SHA256(base64(rawBody), secretKey) compared against x-bold-signature in constant time. In test mode Bold signs with an empty secret, which the plugin handles automatically.
Order ids are unguessable. GET /api/bold/status/:orderId is public by default so a returning customer can confirm their payment before logging in; the order id embeds 10 random characters from a 62-character alphabet (~59 bits) and acts as a bearer credential. Pass access.status to require authentication instead.
Status handling
Bold reports outcomes through two independent channels, and the plugin treats both as first-class:
- Webhooks arrive server-to-server and are authoritative.
- The voucher API is polled when the customer returns, because Bold does not fire webhooks for simulated test-mode transactions. Without this path an integration appears to work in test and silently does nothing in production.
Both funnel through the same transition table, so they cannot disagree:
| From | May move to |
|---|---|
| PENDING | PROCESSING, APPROVED, REJECTED, FAILED, VOIDED |
| PROCESSING | APPROVED, REJECTED, FAILED, VOIDED |
| APPROVED | VOIDED |
| REJECTED, FAILED, VOIDED | nothing |
This makes the integration safe against Bold's retry policy (up to 5 redeliveries over 24 hours) and against out-of-order delivery: a replayed event is ignored by its CloudEvents id, a late SALE_REJECTED cannot undo an approved sale, and NO_TRANSACTION_FOUND never overwrites a real status.
Note that VOID_REJECTED means the void failed, so the sale stays APPROVED.
Bold constraints enforced
COPandUSDonly; minimum 1000 COP- Amounts must be integers with no decimal separators
- Order ids are at most 60 characters of
A-Za-z0-9_- - Taxes accept
vat-5,vat-19,iac-8or a numeric value, and must already be included in the amount
Local development
pnpm install
cp .env.example dev/.env # add your Bold test keys
pnpm dev # sandbox at http://localhost:3000
pnpm testThe sandbox seeds an order, renders a checkout page, and handles the Bold return at /resultado. Bold's test cards: 4111111111111111 approves, 4970110000000062 rejects.
Because Bold does not deliver webhooks for simulated transactions, use the panel's "Probar el webhook" button, or post a signed event yourself — in test mode the HMAC secret is the empty string.
License
MIT
