Stripe API from Airtable Scripts
Airtable is a popular tool for building no-code applications. Iām finding that
knowing just a little bit of JavaScript can really super charge these no-code
solutions. Airtable Scripting
enables you to write a bit of custom JavaScript and wire that up with links and
buttons built into your Airtable base. While itās possible to use tools like
Zapier to wire up some basic API
calls to Stripe, it can be handy to know how to hit the API directly so you can
customize your workflows for your business.
Airtableās scripting environment is not the full Node.js or browser
environment. Instead, itās a custom environment for working within Airtable. It
exposes some helpful methods for working directly with the Airtable base and
some more primitive methods for making HTTP calls either client side or server
side.
Given we donāt have the full Node.js environment, weāre not able to use the
stripe-node client library. Instead,
weāll need to construct requests with form
encoded
bodies and use Airtable Scriptingās
remoteFetchAsync
method
to avoid any blasted CORES problems.
For instance, hereās how you might create a Stripe Customer:
remoteFetchAsync(`https://api.stripe.com/v1/customers`, {
method: 'POST',
headers: {
'Authorization': `Bearer sk_test_ab23bca23bscab23bs`,
'Content-Type': "application/x-www-form-urlencoded",
'Accept': 'application/json',
'User-Agent': 'Airtable tutorial cjav_dev/0.0.1'
},
body: 'email=wave@cjav.dev',
}).then(r => r.json()).then(customer => console.log(customer));
If youāve used the browserās fetch
method, this will be familiar.
From the Airtable Scripting docs, we learn these notable differences from how fetch works in the browser:
- The referrer and referrerPolicy options are not respected. A Referer header is never set. - The follow redirect mode is not supported. Only error and manual are supported. As manual returns an 'opaque' response in order to respect atomic HTTP redirect handling, it's effectively impossible to follow redirects at present. - Streaming responses and requests are not supported. The APIs exist and work as expected, but buffer under the hood. - Caching is not supported. Cache modes can be set, but always behave like reload. - Cookies are not supported. The credentials options can be set, but always behaves like omit. - Different request modes are not supported. They can be set, but none will quite behave as expected. The closest mode in the standard is same-origin, except that requests can be made to any origin. - Subresource integrity is not validated. The integrity metadata property can be set, but is ignored. - The FormData API for request/response bodies is not supported. - The response payload has a size limit of 4.5 MB.
Those differences seem fine to me, so letās talk about how we can improve the experience of writing these scripts when working with the Stripe API.
Perhaps you want to use the API to create a Product, then create a Price, then create a Payment Link for that new Price so that you can use Airtableās other automations to Tweet or send as SMS.
Form encoded bodies
stripe-node has this handy feature where we can pass it JavaScript objects and itāll handle the form encoding before sending in the request body.
For example, when we say:
stripe.customers.create({ email: "wave@cjav.dev", name: "CJ Avilla" })
the client library will translate the params to email=wave@cjav.dev&name=CJ%20Avilla
When params are top level, itās pretty easy to manage by hand, but the Stripe API accepts some really complex arguments to really customize the experience for your users. For instance, itās a little trickier to manually form encode this request body create a price:
stripe.prices.create({
unit_amount: 2800,
currency: 'usd',
recurring: {
interval: 'month'
},
product_data: {
name: 'Box subscription',
metadata: {
sku: 'abc123',
},
images: ['http://placekitten.com/200/300'],
},
});
Instead, weāre going to use this bit of JavaScript thatāll handle most of the cases for us (I donāt remember the source of this example, so if you have the canonical source, let me know so I can link to it!):
// Builds pairs of key and value params where the key is an array of strings representing
// the key path through the object.
// Example:
// { a: 1 } => a=1
// { a: { b: 1 }} => a[b]=1
// { a: [1]} => a[]=1
// { a: [1, 2]} => a[]=1&a[]=2
// { a: {b: {c: 3}}} => a\[b\][c]=3
function buildParamPairs(params) {
let pairs = [];
Object.entries(params).forEach(([key, value]) => {
let keys = [key];
if (Array.isArray(value)) {
value.forEach((val) => {
if (Array.isArray(val)) {
// Not needed for Stripe API patterns, skipping
} else if (typeof val === "object") {
let subPairs = buildParamPairs(val);
subPairs.forEach(([_keys, value]) => {
pairs.push([keys.concat('').concat(_keys), value]);
});
} else {
pairs.push([keys.concat(''), val]);
}
});
} else if (typeof value === "object") {
let subPairs = buildParamPairs(value);
subPairs.forEach(([_keys, value]) => {
pairs.push([keys.concat(_keys), value])
});
} else {
pairs.push([keys, value])
}
});
return pairs;
}
function stringify(params) {
let pairs = [], keyPath = '', bracketedKeys = '';
buildParamPairs(params).forEach(([keys, value]) => {
if(keys.length === 1) {
keyPath = keys[0];
} else {
bracketedKeys = keys.slice(1, keys.length).map(k => `[${k}]`).join('');
keyPath = `${keys[0]}${bracketedKeys}`
}
pairs.push(`${keyPath}=${value}`);
})
return pairs.join("&");
}
Itās a doozy, but it should handle most cases. šµāš«
Now we can update our API call to use this new stringify
method so we can pass JavaScript objects instead of manually form encoding.
remoteFetchAsync(`https://api.stripe.com/v1/customers`, {
method: 'POST',
headers: {
'Authorization': `Bearer sk_test_ab23bca23bscab23bs`,
'Content-Type': "application/x-www-form-urlencoded",
'Accept': 'application/json',
'User-Agent': 'Airtable tutorial cjav_dev/0.0.1'
},
body: stringify({
email: 'wave@cjav.dev'
}),
}).then(r => r.json()).then(customer => console.log(customer));
Handling API keys
We donāt want to store our secret API key in the content of the script.
Ideally, we also create a Restricted API
key
from the Stripe Dashboard so that it only has permission to perform exactly the
actions we need for the automation.
Instead of hardcoding the API key, letās use Airtable Scriptingās
input.config
to accept an API key as part of the scriptās configuration:
NB: The API key you put in the settings of a script are accessible to
anyone with access to the script.
// Click the "gear" icon in the top right to view settings
const config = input.config({
title: 'Your Stripe API Key',
description: 'For making API calls to Stripe',
items: [
input.config.text('STRIPE_SECRET_KEY', {
label: 'Stripe Secret Key',
description: 'Enter an API key from your Stripe Dashboard: https://dashboard.stripe.com/test/apikeys',
}),
]
});
console.log(config.STRIPE_SECRET_KEY)
This way, you have an input where you can drop your restricted API key.
Now we can update our API call to use the API key from the config.
remoteFetchAsync(`https://api.stripe.com/v1/customers`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${config.STRIPE_SECRET_KEY}`,
'Content-Type': "application/x-www-form-urlencoded",
'Accept': 'application/json',
'User-Agent': 'Airtable tutorial cjav_dev/0.0.1'
},
body: stringify({
email: 'wave@cjav.dev'
}),
}).then(r => r.json()).then(customer => console.log(customer));
We still need to pass all these headers and the correct method each time. In my experience, it can be helpful to build out a little class that acts as a wrapper for this call.
Airtable Stripe client
Hereās a basic example of a class that we can use to simplify our API calls by abstracting away the common headers and constructing the remoteFetchAsync call:
class Stripe {
API_BASE = "https://api.stripe.com";
constructor(apiKey) {
this.apiKey = apiKey;
}
async create(path, params, headers) {
return this._request('POST', path, params, headers);
}
async retrieve(path, params, headers) {
return this._request('GET', path, params, headers);
}
async _request(method, path, params, headers) {
return remoteFetchAsync(
`${this.API_BASE}${path}`, {
method,
headers: {...this.headers, ...headers},
body: stringify(params),
}
).then(r => r.json())
}
get headers() {
return {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': "application/x-www-form-urlencoded",
'Accept': 'application/json',
'User-Agent': 'AirtableScripting cmnc/0.0.1'
};
}
}
Weāll construct an instance of the Stripe wrapper:
const stripe = new Stripe(config.STRIPE_SECRET_KEY);
Then our API call to create a customer looks like this š:
stripe.create("/v1/customers", {
email: 'wave@cjav.dev',
})
Hereās a full example of a script for creating a Price and a PaymentLink on the fly using the Stripe API from an Airtable Script:
// Builds pairs of key and value params where the key is an array of strings representing
// the key path through the object.
// Example:
// { a: 1 } => a=1
// { a: { b: 1 }} => a[b]=1
// { a: [1]} => a[]=1
// { a: [1, 2]} => a[]=1&a[]=2
// { a: {b: {c: 3}}} => a\[b\][c]=3
function buildParamPairs(params) {
let pairs = [];
Object.entries(params).forEach(([key, value]) => {
let keys = [key];
if (Array.isArray(value)) {
value.forEach((val) => {
if (Array.isArray(val)) {
// Not needed for Stripe API patterns, skipping
} else if (typeof val === "object") {
let subPairs = buildParamPairs(val);
subPairs.forEach(([_keys, value]) => {
pairs.push([keys.concat('').concat(_keys), value]);
});
} else {
pairs.push([keys.concat(''), val]);
}
});
} else if (typeof value === "object") {
let subPairs = buildParamPairs(value);
subPairs.forEach(([_keys, value]) => {
pairs.push([keys.concat(_keys), value])
});
} else {
pairs.push([keys, value])
}
});
return pairs;
}
function stringify(params) {
let pairs = [], keyPath = '', bracketedKeys = '';
buildParamPairs(params).forEach(([keys, value]) => {
if(keys.length === 1) {
keyPath = keys[0];
} else {
bracketedKeys = keys.slice(1, keys.length).map(k => `[${k}]`).join('');
keyPath = `${keys[0]}${bracketedKeys}`
}
pairs.push(`${keyPath}=${value}`);
})
return pairs.join("&");
}
class Stripe {
API_BASE = "https://api.stripe.com";
constructor(apiKey) {
this.apiKey = apiKey;
}
async create(path, params, headers) {
return this._request('POST', path, params, headers);
}
async retrieve(path, params, headers) {
return this._request('GET', path, params, headers);
}
async _request(method, path, params, headers) {
return remoteFetchAsync(
`${this.API_BASE}${path}`, {
method,
headers: {...this.headers, ...headers},
body: stringify(params),
}).then(r => r.json())
}
get headers() {
return {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': "application/x-www-form-urlencoded",
'Accept': 'application/json',
'User-Agent': 'AirtableScripting cmnc/0.0.1'
};
}
}
// Click the "gear" icon in the top right to view settings
const config = input.config({
title: 'Your Stripe API Key',
description: 'Used for making API calls to Stripe',
items: [
input.config.text('STRIPE_SECRET_KEY', {
label: 'Stripe Secret Key',
description: 'Enter an API key from your Stripe Dashboard: https://dashboard.stripe.com/test/apikeys',
}),
]
});
// Example:
const stripe = new Stripe(config.STRIPE_SECRET_KEY);
// Find the current table and record:
const ordersTable = base.getTable(cursor.activeTableId)
const record = await input.recordAsync('Choose a record', ordersTable);
// Create a new Price
const price = await stripe.create("/v1/prices", {
currency: 'usd',
unit_amount: record.getCellValue("Amount") * 100,
product_data: {
name: 'Coaching session'
}
})
// Create a new PaymentLink
const paymentLink = await stripe.create("/v1/payment_links", {
line_items: [{
price: price.id,
quantity: 1
}],
})
// Store the ID and URL of the PaymentLink
await ordersTable.updateRecordAsync(record, {
'Payment Link ID': paymentLink.id,
'Payment Link': paymentLink.url
});