Payload's Stripe adapter and the 500-character metadata limit: keep the check, store a digest

Payload ecommerce put the whole cart into Stripe PaymentIntent metadata, so checkout failed at about eight items. Removing it fixed checkout and removed the only item-drift check; a 64-character hash keeps both.

Category
Code review
Published
Reading time
3 min read

The problem

Issue #19017, verbatim from the error:

Text
Error: Metadata values can have up to 500 characters, but you passed in a value that is 1090 characters.

The Stripe adapter stored the whole cart in the PaymentIntent metadata as cartItemsSnapshot: JSON.stringify(flattenedCart). Stripe limits each metadata value to 500 characters. At roughly 60 characters per item, checkout broke at about eight items, and the customer only saw "Error initiating payment".

The snapshot was not just carried along. confirmOrder read it back, validateSettlement compared it with the stored transaction items, and the order was created from it.

The proposed fix

initiatePayment already writes the same cart to the transaction, and confirmOrder already loads that transaction. So the pull request drops the snapshot and builds the order from transaction.items:

packages/plugin-ecommerce/src/payments/adapters/stripe/initiatePayment.ts
         metadata: {
           cartID: cart.id,
-          cartItemsSnapshot: JSON.stringify(flattenedCart),
           shippingAddress: shippingAddressAsString,
         },

A new test checks out a 30-item cart and asserts that every metadata value is within 500 characters. Using your own database row as the source of truth, instead of a round trip through a payment provider, is the right direction.

What I found in review

The fix also removed the drift check

validateSettlement used to fail if the items about to be fulfilled differed from the items that were priced when the PaymentIntent was created. The pull request deletes that comparison. The amount check still holds and the transactions collection is admin-only by default, so this is defense in depth rather than an open hole. But the check does not need the items, only a fingerprint of them:

TypeScript
metadata: {
  cartID: cart.id,
  cartItemsHash: createHash('sha256')
    .update(stableSerialize(normalizeCartItems(flattenedCart)))
    .digest('hex'), // 64 characters
  shippingAddress: shippingAddressAsString,
}

validateSettlement would hash normalizeCartItems(transaction.items) the same way and compare. Metadata can only be written with the secret key, so a plain SHA-256 is enough; an HMAC would only matter if an untrusted party could write the stored value. The existing normalization (relationship objects versus ids, key order, null variants) and its tests stay useful.

A rollback would fail charged payments

PaymentIntents live across deployments. Old intents carry cartItemsSnapshot, which the new code ignores, so upgrading is safe. But intents created after the upgrade have no snapshot. If someone deploys this and then rolls back, the old confirmOrder rejects every in-flight payment with "cartItemsSnapshot metadata is missing or invalid", after the customer has been charged. Not a blocker, but worth a line in the release notes.

shippingAddress is the next 500-character bug

The new test loops over every metadata value, but the fixture's address is { city: 'Test City' }. In practice the value is the JSON of whatever address object the client sends; a saved address document can include id, customer, createdAt and updatedAt, and a long company name pushes it over the limit. Storing the address on the transaction, like billingAddress, would remove the last unbounded value.

A call that looks like dead code

normalizeCartItems(transaction.items) was now called only for its throw-on-invalid side effect, with the result discarded. Without a comment, the next reader deletes it.

What happened next

The author replied to every point within the hour:

Takeaways

  • Payment provider metadata is a reference channel with hard size limits, not storage. Keep the data in your database and keep a key or a fingerprint at the provider.
  • When removing data that a check depended on, ask whether the check needs the data or only its identity. A digest is usually enough.
  • Any field that crosses a deploy boundary needs a compatibility story in both directions.
  • When a contributor wants to keep a fix small, help them do that, and write the rest down as an issue.

References

Code and history behind this post

More

Theme