Portreeve
how-to · 7 Sept 202610 min read

Stripe metadata: link every PaymentIntent to your records

Stripe metadata is the join key between your database and Stripe. Write your ids at creation time, read them back in webhooks, and refund the right charge.

Stripe metadata is the join key between your database and Stripe, and most integrations treat it as a debugging convenience. Then a chargeback or a fraud review lands six weeks later, you have a user id and no way to get from it to a pi_, and you are scrolling the Dashboard by amount and date.

You need Node 18 or later, the stripe package, a test-mode secret key, and the Stripe CLI for the verification step. The examples are Express and Postgres.

By the end you will have your own ids on every PaymentIntent at creation time, a webhook handler that resolves the local row straight from the event payload, a metadata search for the times your database is not enough, and a refund job that goes from a user id to the right charge.

What Stripe metadata is, and what it does not do

A flat map of strings to strings, stored on the object. The metadata docs set the limits: 50 key-value pairs, 40 characters per key with no square brackets, 500 characters per value. Everything is a string, so pass a number and you read a string back. Stripe does not use it to authorize or decline a charge unless you write Radar rules against it.

Two things make it the right place for your ids.

The event payload carries it. When Stripe sends an Event to your endpoint it includes "the corresponding object and any metadata the object contains," so a handler can resolve your local record without a second API call.

It stays server-side. Stripe "redacts metadata from objects in response to publishable key requests, such as Stripe.js or Mobile SDKs client-side requests," so a value you set with sk_ does not come back through the browser.

What it will not do is propagate. Metadata "doesn't automatically copy to related objects," and the exceptions are a short documented list. The one that matters here: when a PaymentIntent creates a Charge, the metadata copies to the Charge as a one-time snapshot, and later updates to the PaymentIntent do not follow it.

1. Settle the key names before you write any code

Treat the key names as a contract. Webhook handlers, refund jobs, and whatever you build for support will all read them back, and renaming one later leaves every old object unreachable by the new name.

Three keys carry almost all the value:

  • external_user_id: your user's primary key.
  • order_id: the local row this payment pays for.
  • event_id: the id of the risk or checkout event that authorized this attempt, so you can trace a charge back to the decision that let it through.

Leave out anything that changes, anything with a person in it, and JSON. The blob is what people reach for when 500 characters feels tight, and it costs you the one thing metadata is good for. A metadata clause matches the whole value exactly, so {"user":42,"plan":"pro"} is findable only if you reconstruct that string character for character. Store the id and join in your own database.

Two fields tempt people into skipping all this, and neither one holds up.

description is customer-facing text on receipts, not an index. Stripe's own guidance is to reach for it "if you want to display a single field to customers," which is the point.

client_reference_id on a Checkout Session is real, documented as "a unique string to reference the Checkout Session," but Checkout Sessions are not a searchable resource. The Search API covers Charges, Customers, Invoices, PaymentIntents, Prices, Products, and Subscriptions, and nothing else. Whatever you put in client_reference_id is unqueryable, and it does not reach the PaymentIntent either.

2. Write your ids into PaymentIntent metadata at creation time

On a direct PaymentIntent, set them in the create call. Not in an update afterwards, where a crash between the two calls leaves you an orphan.

const paymentIntent = await stripe.paymentIntents.create({
  amount: order.amount_minor,
  currency: "usd",
  customer: user.stripe_customer_id,
  metadata: {
    external_user_id: String(user.id),
    order_id: String(order.id),
    event_id: verdict.id,
  },
});

await db.query(
  `update orders set stripe_payment_intent_id = $1 where id = $2`,
  [paymentIntent.id, order.id]
);

Write the pi_ back to your own row immediately. The metadata is the recovery path; your column is the fast path.

Do the same on the Customer when you create it, with external_user_id alone. That one key is what turns a Dashboard search into a one-step answer when support asks who cus_NffrFeUfNV2Hib is.

3. Get the metadata onto the PaymentIntent from Checkout

This is where most integrations quietly lose the join. A Checkout Session and the PaymentIntent it creates are two objects, and metadata on the session stays on the session.

Set both:

const session = await stripe.checkout.sessions.create({
  mode: "payment",
  line_items: [{ price: priceId, quantity: 1 }],
  success_url: "https://example.com/done",
  metadata: { order_id: String(order.id) },
  payment_intent_data: {
    metadata: {
      external_user_id: String(user.id),
      order_id: String(order.id),
      event_id: verdict.id,
    },
  },
});

The metadata docs list this under the parameters that set metadata indirectly: data you include with payment_intent_data.metadata saves to the underlying PaymentIntent's metadata. In subscription mode the session has no PaymentIntent to write to, and the equivalent parameter is subscription_data.metadata.

Verify it. Run stripe listen --forward-to localhost:3000/webhooks/stripe, complete a test session with 4242 4242 4242 4242, and watch two events arrive. checkout.session.completed carries metadata.order_id. payment_intent.succeeded carries all three keys. If the second one has an empty metadata: {}, you set the session metadata and forgot payment_intent_data.

4. Read it back in the handler

Now the handler resolves the local record from the payload, with no retrieve call in the hot path.

app.post("/webhooks/stripe", express.raw({ type: "application/json" }), async (req, res) => {
  const event = stripe.webhooks.constructEvent(
    req.body,
    req.get("stripe-signature")!,
    process.env.STRIPE_WEBHOOK_SECRET!
  );

  if (event.type === "payment_intent.succeeded") {
    const pi = event.data.object;
    const orderId = pi.metadata.order_id;
    if (!orderId) return res.sendStatus(200); // not ours, or created before the contract
    await db.query(
      `update orders set state = 'paid', stripe_payment_intent_id = $1
       where id = $2 and state <> 'paid'`,
      [pi.id, orderId]
    );
  }

  res.sendStatus(200);
});

Charge events work the same way, because of that one-time snapshot from the PaymentIntent.

Dispute events do not, and this trips people up. On charge.dispute.created the event object is a Dispute, and a Dispute has its own metadata map, empty unless you set it. What you get is dispute.payment_intent and dispute.charge. Look the payment up in your own table by pi_, or retrieve the PaymentIntent, but do not expect your keys to be sitting on the dispute. If you are wiring up the rest of that path, handling Stripe chargebacks covers what to do with the event once you have found the order.

5. Stripe metadata search, and when not to use it

The clause syntax is metadata["<field>"]:"<value>", and it works on the seven searchable resources:

curl -G https://api.stripe.com/v1/payment_intents/search \
  -u "$STRIPE_SECRET_KEY:" \
  --data-urlencode "query=metadata['external_user_id']:'42' AND status:'succeeded'"

You can also query for a key's presence. metadata["external_user_id"]:null returns the objects that never got the key, which is how you find everything created before you adopted the contract; negating it with a leading - returns the ones that have it.

Four constraints decide where you can use this.

Search is not read-after-write. Stripe says data is "searchable in under 1 minute" under normal conditions, and slower during an outage.

It is rate limited to 20 read operations per second across every search endpoint, with live and test counted separately. A query takes at most 10 clauses, and you cannot mix AND and OR in one.

Filtering runs against a cached copy of the object while the response returns the current version, which produces results that look broken. Stripe documents this for PaymentIntent status; on GitHub, yuliankarapetkov hit it on charges, querying with -refunded:'true' and getting back charges where refunded was true.

So metadata search is a recovery and back-office tool. On a request path, read stripe_payment_intent_id out of your own row. Search is also unavailable to businesses in India, which is reason enough not to put it behind a customer-facing feature.

6. Refund a fraud denial that lands days later

The case the whole contract exists for. A payment was screened at checkout, came back as review, and went through. Days later someone works the queue, decides it was abuse, and now you need to get from a user id to a specific charge.

This is the shape I built Portreeve around: one call at checkout_attempt returns allow, review, or block, and review never blocks the customer. The flow proceeds, the event lands in a queue, and a later deny reaches your server as a signed webhook so you can revoke and refund on your own terms. That is why there is a charge left to refund at all, and why event_id is worth one of your 50 keys. Deliveries retry until they get a 2xx, so the handler has to be idempotent.

import { Portreeve } from "portreeve";
const portreeve = new Portreeve(process.env.PORTREEVE_SECRET_KEY!);

app.post("/webhooks/portreeve", express.raw({ type: "application/json" }), async (req, res) => {
  const event = Portreeve.verifyWebhook(
    req.body,
    req.get("portreeve-signature")!,
    process.env.PORTREEVE_WEBHOOK_SECRET!
  );

  if (event.type === "review.resolved" && event.resolution === "denied") {
    const { rows } = await db.query(
      `select id, stripe_payment_intent_id, refunded_at from orders
       where external_user_id = $1 and state = 'paid' order by created_at desc limit 1`,
      [event.external_user_id]
    );
    const order = rows[0];

    if (order && !order.refunded_at) {
      await stripe.refunds.create(
        { payment_intent: order.stripe_payment_intent_id, reason: "fraudulent" },
        { idempotencyKey: `portreeve-deny-${event.event_id}` }
      );
      await db.query(`update orders set refunded_at = now() where id = $1`, [order.id]);
      await portreeve.feedback(event.event_id, "confirmed_abuse");
    }
  }

  res.sendStatus(200);
});

reason: "fraudulent" on the refund is not decoration. Stripe adds the associated card and email to your block lists when you send it, which is free signal you would otherwise leave on the floor.

The refunded_at guard and the idempotency key together make a retried delivery a no-op rather than a second refund. The feedback call closes the loop: confirmed abuse marks the whole linked cluster, so the next account that links into it arrives already flagged.

If your row is missing the pi_ because it predates step 2, this is the moment metadata search pays for itself. Query PaymentIntents by metadata['external_user_id'] and refund what comes back.

Before you ship this

Backfill or accept the gap. Objects created before the contract have no keys, and metadata["external_user_id"]:null counts them for you. Decide whether to backfill by update call or to let the old ones live only in your database.

Handle the unknown-metadata case explicitly, as in step 4. Test-mode traffic, Dashboard-created payments, and other people's integrations on the same account will all reach your endpoint without your keys.

Do not send more than the ids. The temptation is to mirror the whole order into metadata so the Dashboard is readable. Fifty keys go fast, values truncate at 500 characters, and email addresses in metadata are one export away from somewhere you did not plan for them. Stripe is blunt about the extreme case: never store bank account information or card details there.

If step 6 is why you are reading this, the revoke-on-review webhook pattern covers the account-level version of the same flow, where what you claw back is access rather than money, and handling verdicts is the reference for what each one means.

The free tier is 1,000 screened events a month with no card, and test mode is fully isolated from live, so you can wire the deny-to-refund path end to end before any real money moves. Start there.

← Back to all posts