Skip to main content
We will assume you have a general understanding of programming. This guide is intended for developers, and we expect you to have a good understanding of REST APIs in general. If you’re not a developer, please skip this section.
Custom fields let you collect structured data from customers during the checkout process. You can define up to 3 fields per checkout session, supporting text inputs, numeric inputs, and dropdowns. A practical use case is if you run a Discord server and need to grant roles after purchase. You can add a custom field for discord_username during checkout. When the customer pays, the payment.success webhook event is triggered. You can then fetch the order to retrieve the submitted discord_username value and automate the role assignment. Other examples include collecting license keys, server preferences, referral codes, or any data your fulfillment logic needs.
1

Grab the secrets

In your shop, create a new webhook. The webhook secret will look like this: wh_sk_xxx. Copy and securely store the webhook secret.You’ll also need your Storefront ID for the X-Pandabase-Storefront header.We are storing these secrets as:
.env
PANDABASE_WEBHOOK_SECRET="wh_sk_xxx"
PANDABASE_STOREFRONT_ID="shp_xxxx"
in our .env file.
2

Prepare backend

Set up an Express server with TypeScript. First, initialize your project and install the necessary dependencies:
npm init
npm add express dotenv
npm add -D typescript @types/express @types/node ts-node
npx tsc --init
Create a src folder and add an index.ts file:
src/index.ts
import express from "express";
import crypto from "crypto";
import dotenv from "dotenv";

dotenv.config();

const app = express();
const PORT = process.env.PORT || 3000;

app.use(express.json());

app.listen(PORT, () => {
  console.log(`Server is running on port ${PORT}`);
});
3

Define custom fields in the checkout session

When creating a checkout session via the initialize endpoint, pass a custom_fields array. Each field needs a key, label, type, and optional constraints.There are three field types:
TypeDescriptionConfig options
textFree-form text inputdefault_value, minimum_length, maximum_length
numericDigits-only inputdefault_value, minimum_length, maximum_length
dropdownSelect from a listdefault_value, options (array of label/value pairs)
async function createCheckoutSession(customerEmail: string): Promise<string> {
  const response = await fetch(
    `https://api.pandabase.io/shops/${process.env.PANDABASE_STOREFRONT_ID}/orders/intents/initialize`,
    {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "X-Pandabase-Storefront": process.env.PANDABASE_STOREFRONT_ID!,
      },
      body: JSON.stringify({
        dynamic: true,
        customer_email: customerEmail,
        billing_address: {
          address_line1: "456 Elm Street",
          city: "Springfield",
          state: "IL",
          postal_code: "62701",
          country: "United States",
        },
        products: [
          {
            name: "Premium Plan",
            price: 2999,
            currency: "USD",
            quantity: 1,
          },
        ],
        custom_fields: [
          {
            key: "discord_username",
            label: { type: "custom", custom: "Discord Username" },
            type: "text",
            optional: false,
            text: { minimum_length: 2, maximum_length: 50 },
          },
          {
            key: "server_region",
            label: { type: "custom", custom: "Server Region" },
            type: "dropdown",
            optional: false,
            dropdown: {
              default_value: "us_east",
              options: [
                { label: "US East", value: "us_east" },
                { label: "US West", value: "us_west" },
                { label: "EU West", value: "eu_west" },
              ],
            },
          },
          {
            key: "referral_code",
            label: { type: "custom", custom: "Referral Code" },
            type: "numeric",
            optional: true,
            numeric: { minimum_length: 4, maximum_length: 8 },
          },
        ],
      }),
    }
  );

  const session = await response.json();
  return session.payload.url;
}
The response includes the checkout URL. Redirect your customer there.
4

Build webhook receiver

Add a route to receive webhooks and verify the signature:
function validateSignature(
  req: express.Request,
  res: express.Response,
  next: express.NextFunction
) {
  const signature = crypto
    .createHmac("sha256", process.env.PANDABASE_WEBHOOK_SECRET!)
    .update(JSON.stringify(req.body))
    .digest("hex");

  if (req.headers["x-pandabase-signature"] === signature) {
    next();
  } else {
    res.status(401).send("Invalid signature");
  }
}
5

Handle payment.success with custom fields

When a payment succeeds, the webhook fires a payment.success event. The event.data.order object contains the custom_fields array with both the field definitions and the customer’s submitted values merged together.
app.post("/webhook", validateSignature, (req, res) => {
  const event = req.body;

  if (event.event.type === "payment.success") {
    const order = event.event.data.order;

    if (order?.custom_fields) {
      for (const field of order.custom_fields) {
        console.log(`${field.key}: ${field.value}`);
      }

      const discordUsername = order.custom_fields.find(
        (f: any) => f.key === "discord_username"
      )?.value;

      if (discordUsername) {
        grantDiscordRole(discordUsername);
      }
    }
  }

  res.sendStatus(200);
});

function grantDiscordRole(username: string) {
  console.log(`Granting role to: ${username}`);
}
Each entry in custom_fields on the order looks like:
{
  "key": "discord_username",
  "label": { "type": "custom", "custom": "Discord Username" },
  "type": "text",
  "optional": false,
  "text": { "minimum_length": 2, "maximum_length": 50 },
  "value": "johndoe"
}
6

Send checkout link

Create an endpoint to initiate the payment process and return the checkout URL:
app.post("/create-payment", async (req, res) => {
  try {
    const { email } = req.body;
    const checkoutUrl = await createCheckoutSession(email);
    res.json({ checkoutUrl });
  } catch (error) {
    res.status(500).json({ error: "Failed to create payment session" });
  }
});
7

You've finished!

Field Definition Reference

PropertyTypeRequiredDescription
keystringYesUnique identifier, alphanumeric and underscores, max 200 chars
label.typestringYesMust be "custom"
label.customstringYesDisplay label shown to customer, max 50 chars
typestringYes"text", "numeric", or "dropdown"
optionalbooleanNoDefault: false
text.default_valuestringNoDefault value for text fields
text.minimum_lengthintegerNoMinimum character length (min: 0)
text.maximum_lengthintegerNoMaximum character length (max: 255)
numeric.default_valuestringNoDefault value for numeric fields
numeric.minimum_lengthintegerNoMinimum digit length (min: 0)
numeric.maximum_lengthintegerNoMaximum digit length (max: 255)
dropdown.default_valuestringNoPre-selected option value
dropdown.optionsarrayYes1-200 items, each with { label, value }

Validation Rules

The server strictly validates submitted values:
  • Required fields must have a non-empty value
  • Text: enforces minimum_length and maximum_length constraints
  • Numeric: value must contain only digits, enforces length constraints
  • Dropdown: value must match one of the defined options[].value entries
  • Duplicate keys are rejected
  • Unknown keys not defined in the session are rejected
  • Submitting custom_fields when the session has none defined returns an error
  • Maximum of 3 custom fields per session

Webhook Headers

Every webhook delivery includes these headers:
HeaderDescription
X-Pandabase-SignatureHMAC-SHA256 signature of the JSON body using your webhook secret
X-Pandabase-TimestampUnix timestamp of the delivery
X-Pandabase-IdempotencyUnique delivery ID for deduplication
Failed deliveries are retried up to 5 times with exponential backoff.