Blog / Build in public ·

Status update: working on webhooks you can trust

Developers asked me what Payblast does when store events arrive twice, late, or out of order. This is what shipped, what is in review, and what comes next.

A Alex Founder of Payblast

I started Payblast as a prototype for my own apps. It is a RevenueCat alternative, and it is still early. Lately, a few developers on X have asked me good questions about it. What happens when the same purchase event arrives twice? What if a refund arrives before a delayed purchase? And how do late, out-of-order, or missed webhooks get reconciled?

Before replying, I tried those cases against Payblast, and the answers were not good enough. A notification delivered twice sent the developer webhook twice. A renewal that arrived late could turn access back on after a refund.

I also read public complaints about RevenueCat, Adapty, and other subscription tools, on X, Reddit, GitHub, and community forums. Three things came up again and again: webhooks that arrive late or not at all, events that arrive out of order, and sandbox users counted in real metrics. This is not one vendor's problem. Stores deliver notifications at least once and in no guaranteed order, and every backend has to handle that.

What shipped

The first change is merged. Payblast now applies each event from the App Store, Google Play, and Stripe once, in the order the store signed it, not the order it arrived in.

  • A duplicate delivery is recorded and has no effect. Access does not change and no second webhook goes out.
  • A refund cannot be undone by an older event that arrives late. That event is recorded as stale.
  • Notification types Payblast does not recognize are recorded as ignored. They never grant access.
  • Each customer has an event history at GET /v1/customers/:app_user_id/events, read with your secret key. Every row shows the outcome and the entitlements after it.
  • Customer reads accept ?environment=sandbox, so you can check sandbox and Stripe test-mode purchases. They stay out of production reads.

To check all of this without waiting on a store, I added a replay tool, mix payblast.replay. It plays one story for each store: the same purchase delivered twice, then a refund that arrives before a delayed renewal. This is a simulated replay. Each delivery is signed and goes through the same handlers as the real webhook endpoints, but it all runs locally and no store sandbox is involved.

Terminal output of mix payblast.replay --store all. For the App Store, Google Play, and Stripe, the same four deliveries are recorded as applied, duplicate, applied, and stale. The pro entitlement ends inactive, and two webhooks are sent: initial_purchase and refund.
Simulated replay through the real handlers. For the App Store, Google Play, and Stripe, the four deliveries come out applied, duplicate, applied, and stale. The pro entitlement ends inactive, and only two webhooks go out.

Coming next: webhooks to your server In review

The other half is the webhook Payblast sends to your server. That work is in a pull request that is still in review, so none of this is live yet. When it merges:

  • Payblast answers the store right away and sends your webhook from a durable queue, so a slow endpoint never holds up the store.
  • A failed delivery is retried, waiting from 1 minute up to 24 hours between attempts. Every attempt is logged.
  • Each webhook carries the complete event in a versioned payload: event id, the time the store signed it, a per-customer sequence number, the user's aliases, the entitlements after the event, and the price and period.
  • A delivery log in the dashboard shows each delivery's status, attempts, and last error, with Resend on every row and a Send test event button.
  • If your server misses something, GET /v1/customers/:app_user_id/events?after_sequence=N returns the events after the last sequence number you stored.
Webhook deliveries table in the Payblast dashboard. A test event and three initial_purchase deliveries show Delivered with status 200. A renewal shows Retrying after HTTP 503 on attempt 3 of 7, and an older initial_purchase shows Failed after 7 of 7 attempts with connection refused. Every row has a Resend link, and the card has a Send test event button.
The delivery log on the branch in review, running locally. One delivery is retrying after a 503, one failed after seven attempts and was resent, and the top row is a test event.

The same pull request changes how new customers are counted. Right now, sandbox users are included, the same problem I kept reading about. After the change, a customer counts from the day a store first reports them in production. Sandbox testers, TestFlight, and Stripe test mode only show up when Sandbox data is on.

A demo project with two production buyers and one TestFlight tester. New Customers shows 2 with Sandbox data off and 1 with it on.

What's next

  • Safer account transfers and restore across iOS and the web. When someone buys on an iPhone and later signs in on the web, or restores on a new phone, the purchase should move to the right account once, and your server should hear about it.
  • Real Apple notification handling. Payblast will accept App Store Server Notifications in the signed form Apple sends and verify them against Apple's certificates. Then the replay can run against Apple's own sandbox instead of a simulation.

Most of this update started as someone else's question, so please keep them coming. If webhooks, refunds, or sandbox data have caused you trouble in production, I would like to hear what happened. Reply wherever you found this post, or email hello@bitscorp.co.

Try it with your own app

Create a project, attach your store products, and check one entitlement. One or two apps are free to start, with no card required.