Receive Cards via API / Webhooks
Payments aren't just a payments problem; they're a gateway to innovation across industries. Whether you're building an e-commerce platform, a vertical SaaS solution for logistics or hospitality, or enabling AI agents to handle programmatic purchases, the need to accept sensitive cardholder data through APIs is inevitable. Yet, as you've likely discovered, integrating this data often triggers a compliance nightmare: PCI DSS requirements that balloon your security scope, slow down development, and force tough trade-offs between innovation and risk.
In this guide, we'll show you how to build PCI-free payment APIs using Basis Theory's Proxy to intercept incoming requests and webhooks, seamlessly tokenizing card data before it ever touches your systems. By following these steps, you'll keep your servers and databases out of the sensitive data flow while maintaining full control over routing, customization, and optimization.
Getting Started
To get started, you will need to create a Basis Theory Account and a TEST Tenant.
Provisioning Resources
In this section, we will explore the bare minimum resources to create a Proxy for your API, that will receive cards and store them securely.
Management Application
You will need a Management Application to provision resources. Click here to create one using the Basis Theory Customer Portal.
This will create an application with the following Access Controls:
- Permissions:
application:create
,proxy:create
key
from the created Management Application as it will be used later in this guide.Public Application
You will need a Public Application to authenticate requests. Click here to create one using the Basis Theory Customer Portal.
This will create an application with the following Access Controls:
- Permissions:
token-intent:create
id
from the created Public Application as it will be used later in this guide.Pre-Configured Proxy
Now we will create the Proxy that will listen to HTTP requests containing card data that we need to vault in our Basis Theory Tenant. To achieve that, we will leverage a Request Transform code that handles the request body to create a Token Intent, which temporarily stores the card information and can be later converted to a long-term Token.
The API contract is customizable and can follow any desired format:
- JSON
- XML
- URL Encoded
In this example, we are handling application/json
content type in the request payload.
{
"reference": "REF1234",
"currency": "USD",
"payment_method": {
"number": "4242424242424242",
"expiration_month": 12,
"expiration_year": 2025,
"cvc": "123"
}
}
const { BasisTheoryClient } = require('@basis-theory/node-sdk-2');
module.exports = async function (req) {
const {
applicationOptions: { apiKey },
args: { body, headers }
} = req;
const client = new BasisTheoryClient({ apiKey });
const { payment_method, ...rest } = body;
const tokenIntent = await client.tokenIntents.create({
type: 'card',
data: payment_method,
})
return {
body: {
payment_method: tokenIntent,
...rest
},
headers,
}
};
In this example, we are handling application/xml
content type in the request payload.
<?xml version="1.0" encoding="UTF-8"?>
<Checkout>
<Reference>REF1234</Reference>
<Currency>USD</Currency>
<PaymentMethod>
<Number>4242424242424242</Number>
<ExpirationMonth>12</ExpirationMonth>
<ExpirationYear>2025</ExpirationYear>
<Cvc>123</Cvc>
</PaymentMethod>
</Checkout>
const { create } = require("xmlbuilder2");
const { BasisTheoryClient } = require('@basis-theory/node-sdk-2');
module.exports = async function (req) {
const {
applicationOptions: { apiKey },
args: { body, headers }
} = req;
const client = new BasisTheoryClient({ apiKey });
// parse the xml
const doc = create(body);
// convert it to a Javascript object
const object = doc.end({ format: "object" });
const payment_method = {
number: object.Checkout.PaymentMethod.Number,
expiration_month: object.Checkout.PaymentMethod.ExpirationMonth,
expiration_year: object.Checkout.PaymentMethod.ExpirationYear,
cvc: object.Checkout.PaymentMethod.Cvc,
}
const tokenIntent = await client.tokenIntents.create({
type: 'card',
data: payment_method,
});
// creates an XML replacing the contents of the <PaymentMethod> tag
const transformed = create({
...object,
Checkout: {
...object.Checkout,
PaymentMethod: tokenIntent,
},
}).end();
return {
body: transformed,
headers,
};
};
In this example, we are handling application/x-www-form-urlencoded
content type in the request payload.
reference=REF1234¤cy=USD&paymentMethod[number]=4242424242424242&paymentMethod[expirationMonth]=12&paymentMethod[expirationYear]=2025&paymentMethod[cvc]=123
const querystring = require('querystring');
const { BasisTheoryClient } = require('@basis-theory/node-sdk-2');
module.exports = async function (req) {
const {
applicationOptions: { apiKey },
args: { body, headers }
} = req;
const client = new BasisTheoryClient({ apiKey });
const {
'paymentMethod[number]': number,
'paymentMethod[expirationMonth]': expiration_month,
'paymentMethod[expirationYear]': expiration_year,
'paymentMethod[cvc]': cvc,
...rest
} = querystring.parse(body);
const payment_method = {
number,
expiration_month,
expiration_year,
cvc
}
const tokenIntent = await client.tokenIntents.create({
type: 'card',
data: payment_method,
});
const transformed = querystring.stringify({
...rest,
'paymentMethod[token]': tokenIntent.id
})
return {
body: transformed,
headers,
}
};
Let's store the contents of the requestTransform.js
file into a variable:
request_transform_code=$(cat requestTransform.js)
And call Basis Theory API to create the Proxy:
curl "https://api.basistheory.com/proxies" \
-X "POST" \
-H "BT-API-KEY: <MANAGEMENT_API_KEY>" \
-H "Content-Type: application/json" \
-d '{
"name": "Gateway Proxy",
"destination_url": "https://echo.basistheory.com/anything",
"request_transforms": [
{
"code": '"$(echo $request_transform_code | jq -Rsa .)"'
}
],
"application": {
"id": "<PUBLIC_APPLICATION_ID>"
},
"require_auth": false
}'
Important things to notice in the request above:
<MANAGEMENT_API_KEY>
is the Management Application Key, used to authenticate the request above;destination_url
should be replaced with your API endpoint;request_transform_code
is passed in plaintext form;<PUBLIC_APPLICATION_ID>
is the Public Application id which will be used to create Token Intents;require_auth: false
means that invoking the Proxy won't require a Basis Theory API Key.
key
from the created Proxy as it will be used later to invoke it.Done! These are all the resources necessary. Let's see how to actually use them.
Invoking the Proxy
Let's see how your partners/customers would invoke your API through the Proxy.
- JSON
- XML
- URL Encoded
curl 'https://api.basistheory.com/proxy' \
-X 'POST' \
-H 'Content-Type: application/json' \
-H 'BT-PROXY-KEY: <PROXY_KEY>' \
-d '{
"reference": "REF1234",
"currency": "USD",
"payment_method": {
"number": "4242424242424242",
"expiration_month": 12,
"expiration_year": 2025,
"cvc": "123"
}
}'
curl 'https://api.basistheory.com/proxy' \
-X 'POST' \
-H 'Content-Type: application/xml' \
-H 'BT-PROXY-KEY: <PROXY_KEY>' \
-d '<?xml version="1.0" encoding="UTF-8"?>
<Checkout>
<Reference>REF1234</Reference>
<Currency>USD</Currency>
<PaymentMethod>
<Number>4242424242424242</Number>
<ExpirationMonth>12</ExpirationMonth>
<ExpirationYear>2025</ExpirationYear>
<Cvc>123</Cvc>
</PaymentMethod>
</Checkout>'
curl 'https://api.basistheory.com/proxy' \
-X 'POST' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-H 'BT-PROXY-KEY: <PROXY_KEY>' \
--data-urlencode 'reference=REF1234' \
--data-urlencode 'currency=USD' \
--data-urlencode 'paymentMethod[number]=4242424242424242' \
--data-urlencode 'paymentMethod[expirationMonth]=12' \
--data-urlencode 'paymentMethod[expirationYear]=2025' \
--data-urlencode 'paymentMethod[cvc]=123'
PROXY_KEY
with the Proxy Key you created previously.Your API at destination_url
will be called with the client-informed payload, except the cardholder data would be replaced by the newly created token.
"basistheory.com"
domain? Check the Custom Hostname section.Key Considerations
Token Intents
The Token Intents that we are creating in the Proxy's Request Transform are ephemeral by nature, and were designed to streamline collection and validation from public-facing applications (e.g., checkout pages, payment APIs, etc.). This approach mitigates risks from public API key abuse, reduces unexpected costs from fraudulent Tokens, and optimizes performance for high-volume or untrusted data flows, keeping your systems PCI-free.
Once you receive the request containing the token intent, you can use it to verify or charge the card (e.g., via a payment service provider) before converting the Token Intent to a long-term Token.
Authentication
The Proxy we configured in this guide doesn't require a Basis Theory API Key to be invoked. We strongly recommend that you assert authentication on the incoming requests forward by the Proxy to your endpoint.
If you need to assert authentication before tokenization, you can make a call to your authentication server as the first step in the Request Transform code. For example:
const fetch = require('node-fetch');
const { AuthenticationError } = require('@basis-theory/basis-theory-reactor-formulas-sdk-js');
module.exports = async function (req) {
const { bt, args, configuration } = req;
const { body, headers } = args;
// forwards Authorization header to auth server
const response = await fetch('https://auth.example.com', {
method: 'post',
headers: { 'Authorization': headers['Authorization'] },
});
const { authenticated } = await response.json();
if (!authenticated) {
// returns a 401 to the requester
throw new AuthenticationError();
}
// do tokenization
...
}
Alternatively, if you use JSON Web Tokens (JWT), you can verify the JWT's signature using a public key before the Proxy processes the payload for tokenization. This approach eliminates the need for an additional network request to your APIs, streamlining client authentication and improving performance.
const jose = require('node-jose');
const { AuthenticationError } = require('@basis-theory/basis-theory-reactor-formulas-sdk-js');
const verifySignature = async (token, publicKeyPem) => {
const key = await jose.JWK.asKey(publicKeyPem, "pem");
const verifier = await jose.JWS.createVerify(key);
const result = await verifier.verify(token);
return JSON.parse(result.payload.toString());
};
module.exports = async function (req) {
const { bt, args, configuration } = req;
const { body, headers } = args;
try {
const jwtPayload = await verifySignature(
headers['Authorization'], // getting the JWT from the Authorization header
configuration.PUBLIC_KEY_PEM // getting PEM-encoded RSA public key from the Proxy configuration
);
} catch (error) {
// returns a 401 to the requester
throw new AuthenticationError();
}
// do tokenization
...
}
Monitoring
With requests flowing through Basis Theory API first before hitting your endpoints, it may be tricky to monitor and identify issues that happen top-funnel.
Subscribing to Webhooks is the best way to gain observability in such requests, specifically by ingesting proxy.invoked
events.
Custom Hostname
Requesting your customers or partners to invoke an API such as https://api.basistheory.com/proxy?bt-proxy-key=TDEyQmkhQMpGiZd13FSRQ9
may not be the most elegant approach in some circumstances.
If you want to have a custom hostname like https://secure.yourdomain.com
or https://payments.yourservice.com
for your Pre-Configured Proxy, follow these steps.
Conclusion
The best practices prescribed in this guide ensure that your APIs are compliant with the PCI-DSS standards and your clients' sensitive card data is protected. The tokenIntent.id
forwarded to your API by the Proxy is a synthetic replacement for the sensitive data and can be safely stored in your database, or transmitted through your systems, meeting compliance requirements and reducing the risk of exposure in case of data breaches.
For next steps, take a look at the following guides to proceed taking the most value of your secured card tokens: