Every time someone asks me "should I use the API or a webhook for this?", the answer is almost always "both, but for different jobs." This post is the long-form version of that answer, with code you can copy and the gotchas that bit me in production so you can skip them.
The one-line version
API is what you call. Webhook is what calls you.
That's it. APIs are pull — you ask, the server answers. Webhooks are push — the server tells you when something happened. Most real integrations use both, because you need on-demand reads (API) and real-time event reactions (webhook).
sequenceDiagram
participant You as Your code
participant API as Provider API
Note over You,API: API (pull model)
You->>API: GET /orders?updated_after=...
API-->>You: 200 OK + JSON
You->>API: GET /orders?updated_after=...
API-->>You: 200 OK + JSON (nothing new)
Note over You,API: Webhook (push model)
API->>You: POST /webhook (order.created)
You-->>API: 200 OK
What polling actually looks like
If a provider only offers an API and you need to know about new data, you have to keep asking. Here's the pattern in Python — note the updated_at cursor so you don't fetch the same records twice:
import time
import requests
API_KEY = os.environ["API_KEY"]
cursor = None
while True:
resp = requests.get(
"https://api.example.com/orders",
params={"updated_after": cursor, "limit": 100},
headers={"Authorization": f"Bearer {API_KEY}"},
timeout=10,
)
resp.raise_for_status()
orders = resp.json()["data"]
for order in orders:
process(order)
cursor = order["updated_at"]
if not orders:
time.sleep(300) # 5 min when idle
This works, but think about what it costs. If your provider rate-limits at 100 req/min and you poll every 10 seconds, you're burning 8,640 calls a day to find out about maybe 50 new orders. Most of those calls return nothing. That's why webhooks exist.
What a webhook handler looks like
A webhook is just an HTTP endpoint you implement, that the provider calls when something happens. Minimal Stripe handler in Node.js with Express:
const express = require("express");
const stripe = require("stripe")(process.env.STRIPE_SECRET);
const app = express();
app.post(
"/webhook",
express.raw({ type: "application/json" }), // raw body required for signature
(req, res) => {
const sig = req.headers["stripe-signature"];
let event;
try {
event = stripe.webhooks.constructEvent(
req.body,
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
console.error("Bad signature:", err.message);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
if (event.type === "charge.succeeded") {
handleCharge(event.data.object);
}
// Reply 2xx fast — provider treats anything else as failure
res.json({ received: true });
}
);
app.listen(3000);
Two things worth pointing out:
-
express.raw, notexpress.json. Stripe signs the raw bytes. If you JSON-parse first, signature verification fails. - Reply fast. Stripe waits ~10s. Do the actual work in a queue (BullMQ, SQS, whatever) and ack the webhook immediately, otherwise it retries and you double-process events.
Webhook vs API at a glance
| Criterion | API (pull) | Webhook (push) |
|---|---|---|
| Direction | You → server | Server → you |
| Latency to new data | Depends on poll interval (1–15 min typical) | Under 1 second |
| Server resources | Wasted calls when nothing changed | Zero calls when nothing changed |
| Setup | API key + scheduled job | Public HTTPS endpoint + signature verification |
| Reliability under down | You retry whenever | Provider retries N times, then drops |
| Rate limits | Provider-imposed (e.g. 100/min) | Limited only by event volume |
| Best for | Bulk reads, on-demand actions, reports | Real-time events, low-latency triggers |
Four examples that show the split
Stripe. POST /charges to start a payment (API). Listen for charge.succeeded to know it actually settled (webhook). Building checkout flow on the API response alone is a classic bug — the response says accepted, not settled.
Shopify. New orders come in via orders/create webhook within 1–3 seconds. Bulk-updating 500 product prices? REST API, batched. You'd never poll for orders, and you'd never fire 500 webhooks to update prices.
GitHub. CI subscribes to push and pull_request webhooks to kick off builds. But a bot that comments on every stale PR uses the API — you're asking "what's the current state?", not reacting to an event.
Slack. An "incoming webhook" is the simplest way to post a notification into a channel (you POST to their URL). Need to look up users, manage channels, build an interactive bot? Full API + Slack app required. Webhook for one-way; API for two-way.
Three things that will bite you
1. Signature verification
Anyone can POST to your public URL. Without signature verification, an attacker can fake payment.succeeded events and you'd hand them a product for free. Every serious provider signs the payload — verify before doing anything with it.
Stripe uses Stripe-Signature (built into their SDK, shown above). Shopify uses raw HMAC-SHA256 that you compute yourself:
const crypto = require("crypto");
function verifyShopifyWebhook(rawBody, hmacHeader, secret) {
const computed = crypto
.createHmac("sha256", secret)
.update(rawBody, "utf8")
.digest("base64");
// timingSafeEqual prevents timing attacks
return crypto.timingSafeEqual(
Buffer.from(computed),
Buffer.from(hmacHeader)
);
}
Use timingSafeEqual, not ===. Plain string comparison can leak bits of the signature through timing differences.
2. Idempotency
Webhooks can arrive more than once. Providers retry when they think your endpoint failed — even if it didn't, because their timeout fired before your 200 reached them. Store the event ID and skip duplicates:
async function handle(event) {
const seen = await db.events.findOne({ id: event.id });
if (seen) {
console.log(`Duplicate ${event.id}, skipping`);
return;
}
await db.events.insertOne({ id: event.id, at: new Date() });
await process(event);
}
Keep the dedupe table for 7–14 days. Anything older the provider has already given up retrying.
3. Ordering
Webhooks aren't FIFO. order.shipped can arrive before order.created — especially after a brief outage where retries flow out of order. Design handlers that work regardless: instead of if status === "shipped" then advance from created, check the actual current state from the API before each transition.
Quick decision framework
Use a webhook if the data is event-shaped ("X happened"), latency under a minute matters, and the provider offers one. Payments, orders, signups, status changes.
Use an API if you need to read existing state, do bulk operations, control timing precisely, or the provider doesn't offer webhooks. Reports, end-of-day reconciliation, bulk imports, on-demand lookups.
Use both for real-time triggers with data hydration. Common pattern: webhook arrives with just the order ID, your handler calls GET /orders/{id} to fetch full details (line items, shipping, customer) that didn't fit in the webhook payload. This is also how most no-code automation platforms wire up Stripe → CRM scenarios — webhook trigger, API action.
TL;DR
API = you call. Webhook = you get called. APIs win for reads and writes you initiate. Webhooks win for reactions you don't want to poll for. Most production systems need both — the design question is "which one for which job," not "which one wins."
Add signature verification, idempotency by event ID, and proper logging (otherwise debugging webhook failures is hell) and you've got a system that won't lose data the first time the provider has a bad night.
Originally published on trackstack.tech with a longer walkthrough and decision framework.
Top comments (0)