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:

TypeNameContentPreference
A@192.168.1.2
Awww192.168.1.3
MX@mail01.google.com10
CNAMEblogblog.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:

  1. Parse the incoming DNS question (domain name and record type).
  2. Look up the corresponding records in Stripe.
  3. Format the records into a DNS response packet.
  4. 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."
Metadata in the Stripe dashboard
Metadata in the Stripe dashboard

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.

Great idea
A great idea, clearly

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 Speaker

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!


Share This Article

Related Articles


Lazy loading background images to improve load time performance

Lazy loading of images helps to radically speed up initial page load. Rich site designs often call for background images, which can't be lazily loaded in the same way. How can we keep our designs, while optimising for a fast initial load?

Using Google Sheets as a RESTful JSON API

Save time by not building backends for simple CRUD apps. Use Google Sheets as both a free backend and JSON API endpoint!

Serverless caching and proxying with Cloudflare Workers

Using Cloudflare Workers we can quickly build an effective API proxy, without spinning up any additional hardware. Whether its needing a CORS proxy, speeding up slow APIs via caching, or rate limit management on stingy APIs, this serverless tech is as easy to set up as it is powerful.

Idempotency - what is it, and how can it help our Laravel APIs?

Idempotency is a critical concept to be aware of when building robust APIs, and is baked into the SDKs of companies like Stripe, Paypal, Shopify, and Amazon. But what exactly is idempotency? And how can we easily add support for it to our Laravel APIs?

Calculating rolling averages with Laravel Collections

Rolling averages are perfect for smoothing out time-series data, helping you to gain insight from noisy graphs and tables. This new package adds first-class support to Laravel Collections for rolling average calculation.

More