To collect extra information at checkout with Paystack, add a custom_fields array inside the metadata object when initializing a transaction. Each field needs a display_name (what the customer sees), a variable_name (the machine-readable key), and a value. These fields appear on the Paystack checkout page and come back in webhook payloads under data.metadata.custom_fields.
When you initialize a Paystack transaction, the API accepts a metadata object where you can attach any JSON data you want. Paystack stores that metadata and returns it in verify responses and webhook payloads. Custom fields are a specific structure within metadata that Paystack treats differently from everything else: it actually displays them.
Regular metadata is invisible to customers. If you attach {"order_id": "ORD-142"} to a transaction, that data exists in the API response but never shows up on a checkout page or in a dashboard-friendly format. Custom fields solve this. When you structure data as a custom_fields array inside metadata, Paystack renders each field on the checkout page, includes them in the dashboard transaction view, and makes them available on email receipts.
This matters for three audiences:
variable_name key, which is more reliable than parsing free-form metadata.Every custom field has three required properties, and understanding each one prevents confusion later.
display_name
This is what humans see. It appears as the label on the checkout page, on email receipts, and in the Paystack dashboard. Use natural language with proper capitalization: "Delivery Address", "Phone Number", "Order Notes". This value is purely for display and has no functional purpose in your code.
variable_name
This is what your code uses. It is the machine-readable key you will search for when processing webhook data. Use snake_case with no spaces, no special characters, and no uppercase letters: delivery_address, phone_number, order_notes. Be consistent across your application. If you call it phone_number in one place and customer_phone in another, your webhook handler needs to check for both.
value
The actual data. This is always a string. If you need to pass a number, convert it to a string first. If you need structured data (like multiple items in a cart), consider using regular metadata for that and custom fields only for the human-readable summary.
// Good: clear, consistent naming
const customFields = [
{
display_name: 'Phone Number',
variable_name: 'phone_number',
value: '+254712345678',
},
{
display_name: 'Delivery Address',
variable_name: 'delivery_address',
value: '14 Moi Avenue, Nairobi',
},
];
// Bad: inconsistent, unclear
const badFields = [
{
display_name: 'Phone',
variable_name: 'Phone Number', // spaces, capitalization
value: 254712345678, // number instead of string
},
];
Get weekly developer tips
Join 25,000+ developers. Practical guides, job tips, and new content — straight to your inbox.
No spam. Unsubscribe anytime.
Custom fields go inside the metadata object you pass when calling /transaction/initialize. The structure is an array under the key custom_fields.
const https = require('https');
function initializeWithCustomFields(email, amount, orderDetails) {
const params = JSON.stringify({
email: email,
amount: amount, // in kobo
reference: 'order_' + orderDetails.orderId + '_' + Date.now(),
metadata: {
// Regular metadata for your backend
order_id: orderDetails.orderId,
user_id: orderDetails.userId,
// Custom fields for display
custom_fields: [
{
display_name: 'Order Number',
variable_name: 'order_number',
value: orderDetails.orderId,
},
{
display_name: 'Phone Number',
variable_name: 'phone_number',
value: orderDetails.phone,
},
{
display_name: 'Delivery Address',
variable_name: 'delivery_address',
value: orderDetails.address,
},
{
display_name: 'Special Instructions',
variable_name: 'special_instructions',
value: orderDetails.notes || 'None',
},
],
},
});
const options = {
hostname: 'api.paystack.co',
port: 443,
path: '/transaction/initialize',
method: 'POST',
headers: {
Authorization: 'Bearer ' + process.env.PAYSTACK_SECRET_KEY,
'Content-Type': 'application/json',
},
};
return new Promise(function(resolve, reject) {
const req = https.request(options, function(res) {
let data = '';
res.on('data', function(chunk) { data += chunk; });
res.on('end', function() { resolve(JSON.parse(data)); });
});
req.on('error', reject);
req.write(params);
req.end();
});
}
// Usage
initializeWithCustomFields(
'customer@example.com',
250000, // 2,500 NGN
{
orderId: 'ORD-2026-00387',
userId: 'usr_9k2m',
phone: '+234812345678',
address: '22 Herbert Macaulay Way, Yaba, Lagos',
notes: 'Please leave at the gate',
}
).then(function(result) {
console.log(result.data.authorization_url);
});
Notice how the metadata object contains both regular properties (order_id, user_id) and the custom_fields array. The regular properties are for your backend logic. The custom fields are what shows up on the checkout page.
Here are the custom fields that come up most often in real integrations, with the variable names we recommend standardizing on.
E-commerce delivery
var deliveryFields = [
{ display_name: 'Order Number', variable_name: 'order_number', value: 'ORD-2026-00387' },
{ display_name: 'Delivery Address', variable_name: 'delivery_address', value: '22 Herbert Macaulay Way, Yaba' },
{ display_name: 'Phone Number', variable_name: 'phone_number', value: '+234812345678' },
{ display_name: 'Delivery Notes', variable_name: 'delivery_notes', value: 'Call when arriving' },
];
Event tickets
var ticketFields = [
{ display_name: 'Event', variable_name: 'event_name', value: 'Lagos Tech Summit 2026' },
{ display_name: 'Ticket Type', variable_name: 'ticket_type', value: 'VIP' },
{ display_name: 'Attendee Name', variable_name: 'attendee_name', value: 'Amina Bello' },
{ display_name: 'Number of Tickets', variable_name: 'ticket_count', value: '2' },
];
Service bookings
var bookingFields = [
{ display_name: 'Service', variable_name: 'service_name', value: 'Home Cleaning - 3 Bedrooms' },
{ display_name: 'Date', variable_name: 'booking_date', value: '2026-07-25' },
{ display_name: 'Time Slot', variable_name: 'time_slot', value: '9:00 AM - 12:00 PM' },
{ display_name: 'Contact Phone', variable_name: 'contact_phone', value: '+254700123456' },
];
Donations and fundraising
var donationFields = [
{ display_name: 'Campaign', variable_name: 'campaign_name', value: 'School Building Fund' },
{ display_name: 'Donor Name', variable_name: 'donor_name', value: 'Anonymous' },
{ display_name: 'Dedication', variable_name: 'dedication', value: 'In memory of Dr. Osei' },
];
Notice that all values are strings. Even "Number of Tickets" is passed as the string "2", not the number 2. This keeps the structure predictable and prevents serialization issues.
When Paystack sends a charge.success webhook to your server, the custom fields arrive inside data.metadata.custom_fields as an array. The structure looks exactly like what you sent in, so you can loop through and match on variable_name.
// Express webhook handler
var express = require('express');
var crypto = require('crypto');
var app = express();
app.post('/webhooks/paystack', express.json(), function(req, res) {
// Verify webhook signature first
var hash = crypto
.createHmac('sha512', process.env.PAYSTACK_SECRET_KEY)
.update(JSON.stringify(req.body))
.digest('hex');
if (hash !== req.headers['x-paystack-signature']) {
return res.status(401).send('Invalid signature');
}
var event = req.body;
if (event.event === 'charge.success') {
var data = event.data;
var metadata = data.metadata || {};
var customFields = metadata.custom_fields || [];
// Extract specific fields by variable_name
var phone = getCustomFieldValue(customFields, 'phone_number');
var address = getCustomFieldValue(customFields, 'delivery_address');
var notes = getCustomFieldValue(customFields, 'special_instructions');
console.log('Phone:', phone);
console.log('Address:', address);
console.log('Notes:', notes);
// Fulfill the order with this information
fulfillOrder(data.reference, {
phone: phone,
address: address,
notes: notes,
amount: data.amount,
email: data.customer.email,
});
}
res.sendStatus(200);
});
function getCustomFieldValue(fields, variableName) {
for (var i = 0; i < fields.length; i++) {
if (fields[i].variable_name === variableName) {
return fields[i].value;
}
}
return null;
}
The getCustomFieldValue helper function is worth keeping in a shared utility. You will use it every time you process a webhook that includes custom fields. A few things to watch for:
metadata and custom_fields exist before trying to iterate. Some transactions might not have metadata at all.getCustomFieldValue returns null. Handle this gracefully instead of letting your fulfillment logic crash.data.data.metadata.custom_fields. If your callback handler also needs it, you can extract it there too.Seeing the actual structure helps more than any explanation. Here is a trimmed charge.success webhook payload for a transaction that included custom fields:
{
"event": "charge.success",
"data": {
"id": 3456789012,
"reference": "order_ORD-2026-00387_1721472000000",
"amount": 250000,
"currency": "NGN",
"status": "success",
"customer": {
"email": "customer@example.com",
"customer_code": "CUS_abc123def456"
},
"metadata": {
"order_id": "ORD-2026-00387",
"user_id": "usr_9k2m",
"custom_fields": [
{
"display_name": "Order Number",
"variable_name": "order_number",
"value": "ORD-2026-00387"
},
{
"display_name": "Phone Number",
"variable_name": "phone_number",
"value": "+234812345678"
},
{
"display_name": "Delivery Address",
"variable_name": "delivery_address",
"value": "22 Herbert Macaulay Way, Yaba, Lagos"
},
{
"display_name": "Special Instructions",
"variable_name": "special_instructions",
"value": "Please leave at the gate"
}
]
}
}
}
Notice the structure: data.metadata contains both the regular properties (order_id, user_id) and the custom_fields array. Everything you sent in comes back exactly as you structured it. The regular metadata properties are flat key-value pairs at the top level of metadata. The custom fields are nested in the custom_fields array with their three-property structure intact.
Both custom fields and regular metadata attach data to a transaction. The difference is visibility.
| Feature | Regular Metadata | Custom Fields |
|---|---|---|
| Shows on checkout page | No | Yes |
| Shows in dashboard | As raw JSON | As formatted labels |
| Shows on email receipt | No | Yes |
| Available in webhooks | Yes | Yes |
| Available in verify response | Yes | Yes |
| Structure | Any JSON | Array of {display_name, variable_name, value} |
Use regular metadata when:
Use custom fields when:
Use both together when:
For a deeper look at metadata patterns beyond custom fields, see passing custom data through a transaction.
Custom fields are straightforward, but a few things catch people.
Values must be strings
If you pass a number as the value, Paystack may accept it, but the behavior in the dashboard and on receipts can be inconsistent. Always convert to a string: use String(quantity) or amount.toString() before passing.
Keep the list short
There is no documented maximum number of custom fields, but the checkout page has limited space. Four or five fields is a reasonable ceiling. Beyond that, the checkout page looks cluttered and the customer starts scrolling past walls of text instead of paying. If you have a lot of data, put the essentials in custom fields and the rest in regular metadata.
Do not use custom fields for secret data
Custom fields show up on the checkout page. If you are passing an internal reference like a database primary key or a pricing tier identifier that you do not want the customer to see, use regular metadata instead.
Special characters in values
Values can contain most characters, including commas, periods, and spaces. However, extremely long values (multi-paragraph text) can break the layout on the checkout page. Keep values concise. If a customer needs to enter lengthy special instructions, consider collecting that in your own form and only displaying a summary in the custom field.
The cancel_action metadata key
Paystack recognizes one special metadata key outside of custom fields: cancel_action. Setting metadata.cancel_action to a URL tells Paystack where to redirect the customer if they close the checkout without paying. This is not a custom field. It is a separate metadata property.
KES 2,999
The definitive guide to building payment systems with Paystack — from your first transaction to production-grade payment infrastructure across Africa. 9 modules, 47 lessons. Free preview available.
Start Paystack Course