Stripe Is My DNS Provider Now: When Good APIs Meet Bad Ideas
I've always believed the best way to really understand a technology is to push it in stupid, impractical directions and see what breaks. Inspired by Corey Quinn's claim that Amazon Route 53 is actually a database, I wondered: what's the opposite of that? If DNS can be used as a database, what's the most absurd way to implement a DNS server?
That thought led me to this project - using Stripe metadata as the datastore for a fully functional DNS server.
Metadata
When working with Stripe, Metadata is a really important concept. It greatly simplifies linking Stripe's data model to your own application. Order ids, loyalty programme membership, referral scheme ids - Stripe doesn't know or care what these concepts in your application are, but allows us to store the info on core Stripe objects to simplify linking the two systems together.
You can use it to tag a customer with an internal ID:
"internal_user_id": "abcd1234"
Or track who triggered a refund:
"refunded_by": "support_team_bot"
Ultimately metadata is a simple key-value store where you can store arbitrary values on core Stripe objects. Customers, Subscriptions, Payment Intents, Products, etc - all the core Stripe objects have a metadata
field, in which you can store up to 50 key-value pairs on each object. While Stripe metadata is just a key-value store, with some clever flattening and serialisation (using techniques like JSON.stringify()
), it can be coerced into storing structured DNS records - if you're willing to abuse a few best practices along the way.
DNS records are essentially a mapping of record -> location, which sounds an awful lot like something suitable for key-value mapping.
Setting up the DNS server
For the DNS server, we'll use dns2. This gives us the ability to listen for DNS queries and craft responses:
const dns2 = require('dns2');
class StripeDnsServer {
constructor(options = {}) {
this.stripeClient = options.stripeClient || stripe;
this.port = options.port || 5333;
this.address = options.address || '0.0.0.0';
this.server = this._createServer();
}
_createServer() {
return dns2.createServer({
udp: true,
handle: this._handleRequest.bind(this)
});
}
start() {
this.server.listen({
udp: {
port: this.port,
address: this.address,
type: "udp4",
},
tcp: {
port: this.port,
address: this.address,
},
});
return this;
}
stop() {
if (this.server) {
this.server.close();
}
}
}
// Create and start the server
const dnsServer = new StripeDnsServer({
port: process.env.DNS_PORT || 5333
}).start();
This will listen on port 5333 (UDP and TCP) and pass incoming requests to a _handleRequest
method, which we still need to define. It won't respond with anything useful yet, but it's a start!
Structuring DNS Records for Metadata Storage
When working with DNS, you'll typically have records like the below:
Type | Name | Content | Preference |
---|---|---|---|
A | @ | 192.168.1.2 | |
A | www | 192.168.1.3 | |
MX | @ | mail01.google.com | 10 |
CNAME | blog | blog.wordpress.com. |
Ideally, we'd want to represent this in a structured format like JSON:
{
"@": { "A": ["192.168.1.2"] },
"www": { "A": ["192.168.1.3"] },
"blog": { "CNAME": ["blog.wordpress.com"] },
"_mx": {
"MX": [
{ "preference": 10, "exchange": "mail01.google.com" }
]
}
}
Our first problem with mapping this to Stripe's metadata is that we've got more than just a simple key-value relationship here, with more complex structures. However Stripe metadata doesn't support complex structures, so arrays and nested data structures won't work out of the box. It's strictly key-value, where the value is a string (up to 500 characters).
That limitation doesn't stop us, it just means we have to flatten the data ourselves first using JSON.stringify()
before storing it, and then re-expand it using JSON.parse()
upon retrieval. This makes things like filtering in Stripe's dashboard less effective, but for an API-driven integration, it works. "Supported, if not encouraged," as they say!
We'll store the entire JSON structure as a string under a single metadata key, dns_records
:
metadata.dns_records: '{"blog":{"CNAME":["blog.example.net."]},"www":{"A":["192.168.1.3"]},"@":{"A":["192.168.1.2"],"MX":[{"priority":10,"exchange":"mail1.example.com."}]}}'
Serialising the data into the key means we can store all our DNS records under one key. Stripe has a limit of 500 characters for metadata values. If you've got a lot of records, you'll probably want to split the dns_records
key into something like dns_records_A
, dns_records_MX
, etc. But then if you've got a lot of DNS records to worry about, chances are you're not going to be running your DNS service using Stripe metadata in the background, so hopefully a moot concern there!
To associate this with a domain, we add another metadata key: dns_domain = example.com
.
Storing the Data in Stripe
Where do we attach this metadata? Stripe Customer objects are perfect! We can create them via the API without needing payment methods or actual financial data. We're effectively using Stripe as a (very unusual) free-tier key-value database.
const customer = await stripe.customers.create({
name: 'DNS records for example.com',
metadata: {
dns_domain: 'example.com',
dns_records: '{"blog":{"CNAME":["blog.example.net."]},"www":{"A":["192.168.1.3"]},"@":{"A":["192.168.1.2"],"MX":[{"priority":10,"exchange":"mail1.example.com."}]}}'
}
});
Answering DNS queries
Now, let's implement the _handleRequest
method in our StripeDnsServer
class. It needs to:
- Parse the incoming DNS question (domain name and record type).
- Look up the corresponding records in Stripe.
- Format the records into a DNS response packet.
- Send the response back to the client.
// Import Packet for response crafting
const Packet = require('dns2').Packet;
async _handleRequest(request, send) {
const response = Packet.createResponseFromRequest(request);
const [question] = request.questions;
const { name, type } = question;
// Get domain parts
const { topDomain, subdomain } = this.parseDomainParts(name);
// Look up records
const recordSet = await this.lookupDnsRecords(topDomain, subdomain);
// Add records to response if found
if (recordSet) {
this.addRecordsToResponse(response, name, recordSet, type);
} else {
console.log('No matching records found');
}
send(response);
}
// Helper function to split domain (add this to the class or outside)
parseDomainParts(fqdn) {
const domainParts = name.split('.');
const topDomain = domainParts.slice(-2).join('.');
const subdomain = domainParts.slice(0, -2).join('.') || '@';
return { topDomain, subdomain };
}
(Note: A robust parseDomainParts
is complex due to TLD variations; this is a simplified version for the demo)
Looking Up Records: The Challenge with Search API
Our first instinct for lookupDnsRecords
might be to use Stripe's Search API, which allows searching objects based on metadata. We stored dns_domain
, so we could search for the customer matching the requested domain:
async lookupDnsRecords(domain, subdomain) {
const customers = await stripe.customers.search({
query: "metadata['dns_domain']:'${domain}'"
});
if (customers.data.length === 0) {
console.log(`No customer found for domain ${domain}`);
return null;
}
const dnsRecords = JSON.parse(customers.data[0].metadata.dns_records || '{}');
// Check for exact match
let recordSet = dnsRecords[subdomain];
// Check for wildcard if no exact match
if (!recordSet && subdomain !== '@') {
recordSet = dnsRecords['*'];
}
return recordSet || null;
}
This seems fine. However, there's a critical flaw: Stripe's Search API is eventually consistent. The documentation explicitly warns:
Don't use search for read-after-write flows (for example, searching immediately after a charge is made) because the data won't be immediately available to search. Under normal operating conditions, data is searchable in under 1 minute. Propagation of new or updated data could be delayed during an outage.
A potential one-minute delay between creating/updating a DNS record and it being resolvable is not great for our DNS server. We need a reliable, immediate read-after-write mechanism.
Looking Up Records: The Workaround using List API
Luckily, the standard list
operations on Stripe objects (like stripe.customers.list
) are strongly consistent for certain filters. While you can't directly filter on arbitrary metadata with list
, you can filter on standard fields like email
.
So, the workaround: when creating the Customer, we'll store a unique identifier for the domain in the email
field. Let's use dns@
followed by the domain name.
Update the Customer creation logic:
const domain = 'example.com';
const customer = await stripe.customers.create({
name: `DNS records for ${domain}`,
email: `dns@${domain}`, // Use email for consistent lookup!
metadata: {
dns_domain: domain,
dns_records: '...' // The JSON string
}
});
Now, we can implement lookupDnsRecords
using stripe.customers.list
filtered by email
, which gives us immediate consistency:
// Corrected lookup function - inside StripeDnsServer class
async lookupDnsRecords(domain, subdomain) {
const lookupEmail = `dns@${domain}`;
console.log(`Listing customers with email: '${lookupEmail}'`);
const customers = await this.stripeClient.customers.list({
email: lookupEmail,
limit: 1
});
...
Formatting the DNS Response
Once lookupDnsRecords
returns the relevant recordSet
(e.g. { "A": ["192.168.1.3"] }
), the addRecordsToResponse
method formats these into the DNS answer section:
addRecordsToResponse(response, name, recordSet, type) {
switch (type) {
case Packet.TYPE.A:
if (recordSet.A) {
recordSet.A.forEach(ip => {
this.addRecord(response, name, Packet.TYPE.A, { address: ip });
});
return true;
} else if (recordSet.CNAME) {
this.addRecord(response, name, Packet.TYPE.CNAME, { domain: recordSet.CNAME[0] });
return true;
}
break;
case Packet.TYPE.CNAME:
if (recordSet.CNAME) {
this.addRecord(response, name, Packet.TYPE.CNAME, { domain: recordSet.CNAME[0] });
return true;
}
break;
case Packet.TYPE.MX:
const mxRecords = recordSet.MX || [];
mxRecords.forEach(mx => {
this.addRecord(response, name, Packet.TYPE.MX, {
priority: mx.priority,
exchange: mx.exchange
});
});
return mxRecords.length > 0;
}
return false;
}
Test Implementation
A demo implementation is available on Github. It includes a server, as well as a script to create the new records within Stripe. Install it, add your Stripe secret key to the env file, run node server.js
, and you're up and running!
Creating and updating DNS records
The script dns-update.js
will create or update the Customer record for a given domain, storing the DNS records in its metadata and setting the dns@<domain>
email.
Usage:
node dns-update.js <domain> <subdomain> <recordType> <value>
Examples:
node dns-update.js example.com www A 192.168.1.1
node dns-update.js example.com @ A 192.168.1.2,192.168.1.3
node dns-update.js example.com blog CNAME blog.example.net.
node dns-update.js example.com @ MX "10 mail1.example.com.,20 mail2.example.com."

Running the server
node server.js
By default this will create a server listening on port 5333
locally (0.0.0.0
). These settings can be updated in the constructor of the server:
constructor(options = {}) {
this.stripeClient = options.stripeClient || stripe;
this.port = options.port || 5333;
this.address = options.address || '0.0.0.0';
this.server = this._createServer();
}
Querying the DNS Server
Use dig
or a similar tool to query your running server (replace example.com
with the domain you added records for):
> dig @127.0.0.1 -p 5333 example.com
;; QUESTION SECTION:
;example.com. IN A
;; ANSWER SECTION:
example.com. 300 IN A 192.168.1.2
example.com. 300 IN A 192.168.1.3
> dig @127.0.0.1 -p 5333 example.com MX
;; QUESTION SECTION:
;example.com. IN MX
;; ANSWER SECTION:
example.com. 300 IN MX 10 mail1.example.com.
example.com. 300 IN MX 20 mail2.example.com.
Limitations (There are many!)
- Metadata Size Limit: The 500-character limit per metadata value is a hard constraint. Complex zones will quickly exceed this. Splitting keys adds significant complexity.
- Speed: This is slow. Stripe API calls are generally quite fast as APIs go, but for DNS servers ultra-fast response times are key.
- No Caching: There's no DNS caching implemented server-side. Every query hits the Stripe API. The TTL in responses is fixed.
- Limited Record Types: Only A, CNAME, and MX are implemented here. Adding TXT, AAAA, SRV, etc., requires more parsing and handling.
- Error Handling/Testing: This is proof-of-concept code. Proper error handling, input validation, and testing are minimal.
There are doubtless plenty more limitations - this is a joke project, without proper error handling or testing. If you even considered running this in production, you are a danger to yourself and others. But if you're experimenting with key-value storage, APIs, or just want to learn how far you can push a flat storage model - this project's for you!
What exactly is the point of all of this?
Is this a good idea? No.
But is it a viable option if you're in a hurry? Also no.
Ah, but is it ultimately a practical application of this technology? Absolutely not - it is the jokiest of joke projects.

This project is definitely not production-grade (please don't...), but it does show:
- How Stripe metadata can be twisted into storing structured data.
- The limits of flat key-value storage, and how to work around them.
- The importance of understanding API consistency models (Search vs List).
- That "bad ideas" are often the best teaching tools.
The goal here is to show that metadata can be an incredibly powerful and flexible technology - if it can support a functional (albeit terrible) DNS server, then imagine the more practical uses it can bring to your own payment-related workflows!
CyberWiseCon 2025
In May 2025, I'll be presenting Digital Cat-and-Mouse: Strategies to Outsmart Scrapers, Phishers, and Thieves at CyberWiseCon in Vilnius, Lithuania. From selling 10 Downing St, to moving the Eiffel Tower to Dublin, this talk covers real-world examples of unconventional ways to stop scrapers, phishers, and content thieves. You'll gain practical insights to protect assets, outsmart bad actors, and avoid the mistakes we made along the way!
Get your ticket now and I'll see you there!