Hosting e-commerce webhook relays and background workers: Railway vs a self-hosted VPS
Disclosure: some links in this guide are affiliate links. If you sign up through them, I may earn a commission at no extra cost to you. How this site makes money.
Run the webhook receiver as a small always-on service that verifies the signature, writes the event to disk and answers 200 in milliseconds, with a worker forwarding it in the background. Our tested relay used about 31 MB of RAM and 6 ms of CPU per webhook, so on Railway, which bills measured usage per second, it costs well under the $5 that the Hobby plan already includes: $5 a month, deployed from Git. A $5 Vultr or $6 DigitalOcean VPS costs the same and needs you to run Docker, TLS and updates. Railway gets expensive for workers that keep a full vCPU or several GB busy around the clock: a 1 vCPU / 1 GB worker running 24/7 is about $30 a month on Railway against $10 to $12 for a 2 GB VPS. Don't enable Railway's Serverless sleep on a webhook receiver.
Every store integration ends up with the same small service: something that receives Shopify or WooCommerce webhooks and does something with them. It pushes orders to an ERP or a 3PL, syncs stock, or posts to Slack. The code is short. Where it runs decides whether orders get lost. A webhook receiver has to answer within seconds, survive deploys and restarts, and keep events that arrive while the system it forwards to is down. This guide builds that service once, measures what it actually consumes, and compares running it on Railway with running it on a VPS of your own.
Pricing and platform behavior come from Railway's pricing page and documentation (plans, app sleeping, volumes, cron jobs, config as code), the public Vultr plans API, DigitalOcean's Droplet pricing page, and the Shopify and WooCommerce webhook documentation, checked in October 2026. The relay was run on a Windows laptop against a local mock of the downstream API, including a load test of 5,000 webhooks; CPU and memory figures come from that run. It was not deployed to Railway, Vultr or DigitalOcean, so platform behavior is as documented, not observed. The Docker Compose file was not run.
1. The short answer
| Railway | VPS (Vultr, DigitalOcean) with Docker | |
|---|---|---|
| Deploy | Push to Git; build, TLS and domain are handled | You set up Docker, a reverse proxy, TLS and updates |
| Billing | Measured CPU and memory per second; $5 Hobby plan includes $5 of usage | Fixed monthly price for reserved resources |
| Small relay (measured footprint) | $5 a month | $5 (Vultr) to $6 (DigitalOcean) a month |
| Busy worker, 1 vCPU and 1 GB all day | About $30 a month | $10 to $12 a month for 1 vCPU / 2 GB |
| Restarts and deploys | Automatic restarts; a short gap on each deploy when a volume is attached | Up to you: restart policy, zero-downtime deploys if you build them |
| Fits | Several small services, cron jobs, teams without an ops person | Steady heavy workloads, or one box that already runs other things |
2. The architecture: acknowledge fast, forward later
The mistake that costs orders is doing the work inside the webhook request. Shopify waits 5 seconds for a response, and WooCommerce counts anything other than a 2xx (or a redirect) as a failed delivery. If your handler calls a slow ERP and the ERP takes 6 seconds, the store records a failure even though you received the event. Shopify retries 8 times over 4 hours and can remove a subscription that keeps failing. WooCommerce doesn't retry at all: the event is gone, and after more than 5 consecutive failures (the default) it disables the webhook until someone switches it back on.
Three properties matter, and the test checks each one:
- Durable before acknowledged. The event is committed to disk before the 200 goes out. A crash after that loses nothing.
- Idempotent on redelivery. Shopify and WooCommerce resend with the same delivery ID (
X-Shopify-Webhook-Id,X-WC-Webhook-Delivery-ID), so the insert ignores duplicates. - Retries live in the relay, not the store. When the downstream system is down for an hour, events wait in the queue instead of burning the store's retry budget.
3. The relay
One file, standard library only. The same code runs on Railway and on a VPS: PORT comes from the platform, and the queue lives at RELAY_DB, a path on a volume.
"""Webhook relay for Shopify and WooCommerce: verify the signature, store the event on disk, answer 200 at once,
and forward it from a background worker with retries. Python 3.10+, standard library only."""
import base64, hashlib, hmac, json, os, sqlite3, threading, time, urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
DB = os.environ.get('RELAY_DB', '/data/relay.db') # a Railway volume or a VPS disk: survives restarts and deploys
TARGET = os.environ['RELAY_TARGET'] # where events go: your ERP sync, n8n, a fulfilment API...
RETRY_BASE = float(os.environ.get('RELAY_RETRY_BASE', 30)) # seconds before the first retry, doubling after
MAX_ATTEMPTS = int(os.environ.get('RELAY_MAX_ATTEMPTS', 10)) # 30 s doubling 10 times covers about 8.5 hours
# path -> (secret, signature header, delivery ID header, topic header); both sign with base64 HMAC-SHA256
SOURCES = {
'shopify': (os.environ.get('SHOPIFY_WEBHOOK_SECRET'),
'X-Shopify-Hmac-Sha256', 'X-Shopify-Webhook-Id', 'X-Shopify-Topic'),
'woocommerce': (os.environ.get('WC_WEBHOOK_SECRET'),
'X-WC-Webhook-Signature', 'X-WC-Webhook-Delivery-ID', 'X-WC-Webhook-Topic'),
}
SOURCES = {k: v for k, v in SOURCES.items() if v[0]} # no secret, no endpoint: never accept unsigned events
# One connection shared by all threads: SQLite takes one writer at a time anyway
_conn = sqlite3.connect(DB, timeout=30, isolation_level=None, check_same_thread=False) # autocommit
_conn.execute('PRAGMA journal_mode=WAL')
_lock = threading.Lock()
def sql(query, args=()):
with _lock:
return _conn.execute(query, args).fetchall()
sql('''CREATE TABLE IF NOT EXISTS events (id TEXT PRIMARY KEY, source TEXT, topic TEXT, body BLOB,
received REAL, attempts INT DEFAULT 0, next_at REAL DEFAULT 0, status TEXT DEFAULT 'pending')''')
sql('CREATE INDEX IF NOT EXISTS due ON events (status, next_at)')
class Relay(BaseHTTPRequestHandler):
def do_GET(self):
if self.path == '/healthz': # liveness for Railway's deploy check: never fail it over a backlog
return self.reply(200, {'ok': True})
if self.path != '/status':
return self.reply(404, {'error': 'not found'})
rows = sql('SELECT status, COUNT(*), MIN(received) FROM events GROUP BY status')
stats = {s: n for s, n, _ in rows}
oldest = min([r for s, _, r in rows if s == 'pending'] or [time.time()])
stats['oldest_pending_s'] = round(time.time() - oldest)
# For an uptime monitor: 503 when events wait over 15 minutes (downstream down) or any gave up
self.reply(503 if stats['oldest_pending_s'] > 900 or stats.get('dead') else 200, stats)
def do_POST(self):
source = self.path.rstrip('/').rsplit('/', 1)[-1] # /webhooks/shopify, /webhooks/woocommerce
raw = self.rfile.read(int(self.headers.get('Content-Length') or 0))
if source not in SOURCES:
return self.reply(404, {'error': 'unknown source'})
secret, sig_h, id_h, topic_h = SOURCES[source]
if source == 'woocommerce' and raw.startswith(b'webhook_id=') and not self.headers.get(sig_h):
return self.reply(200, {'ok': 'ping'}) # WooCommerce pings new webhooks unsigned; store nothing
expected = base64.b64encode(hmac.new(secret.encode(), raw, hashlib.sha256).digest())
if not hmac.compare_digest(expected, (self.headers.get(sig_h) or '').encode()):
return self.reply(401, {'error': 'bad signature'})
# Retries reuse the delivery ID: INSERT OR IGNORE makes a redelivered event a no-op
event_id = '%s:%s' % (source, self.headers.get(id_h) or hashlib.sha256(raw).hexdigest())
sql('INSERT OR IGNORE INTO events (id, source, topic, body, received) VALUES (?, ?, ?, ?, ?)',
(event_id, source, self.headers.get(topic_h, ''), raw, time.time()))
self.reply(200, {'ok': True}) # no forwarding here: the store gets its answer in milliseconds
def reply(self, code, obj):
body = json.dumps(obj).encode()
self.send_response(code)
self.send_header('Content-Type', 'application/json')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *a):
pass
def worker():
while True:
rows = sql("""SELECT id, source, topic, body, attempts FROM events WHERE status = 'pending'
AND next_at <= ? ORDER BY next_at, received LIMIT 1""", (time.time(),))
if not rows:
# Keep delivered IDs for 2 days: Shopify stops retrying after 4 hours, so that's ample for dedupe
sql("DELETE FROM events WHERE status = 'done' AND received < ?", (time.time() - 2 * 86400,))
time.sleep(1)
continue
event_id, source, topic, body, attempts = rows[0]
try:
# At-least-once: a crash between this call and the UPDATE resends the event, so the
# receiver should dedupe on X-Relay-Event-Id
headers = {'Content-Type': 'application/json', 'X-Relay-Event-Id': event_id,
'X-Relay-Source': source, 'X-Relay-Topic': topic}
urllib.request.urlopen(urllib.request.Request(TARGET, body, headers), timeout=15).close()
sql("UPDATE events SET status = 'done', body = NULL WHERE id = ?", (event_id,)) # orders are 5-20 KB
except OSError: # connection errors, timeouts and HTTP 4xx/5xx (HTTPError is an OSError)
attempts += 1
sql('UPDATE events SET attempts = ?, next_at = ?, status = ? WHERE id = ?',
(attempts, time.time() + RETRY_BASE * 2 ** (attempts - 1),
'dead' if attempts >= MAX_ATTEMPTS else 'pending', event_id))
if __name__ == '__main__':
threading.Thread(target=worker, daemon=True).start()
ThreadingHTTPServer(('0.0.0.0', int(os.environ.get('PORT', 8080))), Relay).serve_forever() # Railway sets PORT
- No secret, no endpoint. A source without a configured secret returns 404, so a missing environment variable can't open an unsigned endpoint.
- WooCommerce's ping. When you save a webhook, WooCommerce sends an unsigned
webhook_id=…request to check the URL. The relay answers 200 and stores nothing. - Delivered events lose their body. Order payloads run 5 to 20 KB. Keeping only the ID of delivered events for 2 days covers Shopify's 4-hour retry window and keeps the volume small.
- At-least-once, not exactly-once. If the relay dies between forwarding and marking an event done, it forwards again after restart. The downstream side should ignore an
X-Relay-Event-Idit has already processed. /healthzand/statusare separate on purpose. Railway uses a health check path to decide whether a new deploy goes live. If that check failed whenever the downstream API was down, you couldn't deploy a fix during an outage./statusreturns 503 when an event has waited over 15 minutes or one has given up; point an uptime monitor at it.
4. Test results
The test runs the relay as a separate process against a mock downstream API that can fail on command:
| Scenario | Expected | Result |
|---|---|---|
| Shopify webhook, valid signature | 200, forwarded | Pass |
| Forged signature | 401, not stored | Pass |
| Same delivery ID sent 3 times | Forwarded once | Pass |
| WooCommerce webhook and the WooCommerce ping | Event forwarded; ping answered, not stored | Pass |
| Downstream returns 503 three times | Retried, delivered once | Pass |
| Downstream rejects an event every time | Dead letter after 4 attempts; /status 503, /healthz 200 | Pass |
Downstream down, 20 events queued, relay killed with taskkill /F, restarted | All 20 delivered after restart | Pass |
| 5,000 order webhooks from 8 concurrent senders | All acknowledged and forwarded | Pass: p50 27 ms, p99 50 ms to acknowledge |
Resource use during that load test: 28 MB of memory idle and 31 MB after the 5,000 webhooks, and about 6 ms of CPU per webhook, covering receiving, writing to disk and forwarding. The single worker forwarded about 45 events a second; that's the ceiling to watch during a flash sale, and the fix is more worker threads, not a bigger server.
5. What it costs
Railway bills the CPU and memory a service actually uses, per second: $20 per vCPU-month, $10 per GB-month of memory, $0.15 per GB-month of volume storage and $0.05 per GB of egress. The Hobby plan is $5 a month and that $5 counts toward usage, so any month under $5 of usage costs exactly $5. A VPS costs its list price whether it's idle or saturated.
For a busy store sending 50,000 webhooks a day, the measured footprint works out to:
| Railway resource | Usage | Per month |
|---|---|---|
| CPU | 50,000 × 6 ms = 5.4 CPU-minutes a day, about 0.004 vCPU on average | About $0.08 |
| Memory | About 31 MB, all month | About $0.30 |
| Egress | Forwarding 50,000 × 15 KB a day to a public URL, about 22 GB | About $1.10 |
| Volume | Well under 1 GB | Under $0.15 |
| Bill | About $1.60 of usage, inside the included $5 | $5 |
Our CPU and memory were measured on a Windows laptop; a Linux container will differ somewhat, but not by the 3× it would take to change the bill. Egress to a downstream service on Railway's private network isn't public egress.
The break-even is simple: on Railway, every GB of memory a service holds all month is $10, and every vCPU it keeps busy is $20. A relay or an hourly sync job costs cents. A worker that resizes product images, renders feeds or runs headless browsers all day costs several times a VPS with the same capacity.
6. Sleep, cold starts and deploys
Railway's Serverless setting puts a service to sleep when it sends no outbound traffic for about 5 minutes (in practice 5 to 10, since inactivity is sampled). The next inbound request wakes it, with a delay, and Railway warns that this first request may get a 502. For a webhook receiver that's the wrong trade:
- WooCommerce doesn't retry. A 502 on wake-up is a lost event and one of the 5 failures before the webhook is disabled.
- Shopify retries, so nothing is lost, but the order arrives late. The first retry comes after a delay, not immediately.
- Scheduled retries stall. This relay's worker reads a local SQLite file, which isn't outbound traffic. With the downstream down and the next retry 30 minutes away, the service could fall asleep with events in its queue, and they'd wait until the next webhook wakes it. We read this from Railway's documented sleep trigger; we didn't run it on Railway.
At this relay's footprint, sleeping would save well under a dollar a month. Leave Serverless off for receivers. It's fine for an internal admin tool or a preview environment.
Deploys are the other gap. Railway documents a short period of downtime when redeploying a service that has a volume attached, health check or not, because two deployments can't mount the same volume at once. For the same reason a service with a volume can't have replicas. Webhooks that arrive in that window fail: Shopify retries them, WooCommerce doesn't. On a VPS you have the same problem with a plain docker compose up -d, because the container is replaced. If WooCommerce events matter, add a reconciliation job that lists orders modified since the last run, so anything missed during a deploy is caught within the hour.
For jobs that run on a schedule, like a nightly feed export or an hourly stock sync, use a Railway cron service instead of a loop that sleeps: it starts on the schedule, must exit when done, and only uses resources while running. Schedules are in UTC, runs must be at least 5 minutes apart, and if the previous run is still going the new one is skipped.
7. Running it on Railway
- Push
relay.pyto a Git repository and create a service from it. Railway detects Python; set the start command topython relay.py. - Attach a volume mounted at
/data. Without one, the SQLite queue is wiped on every deploy. - Set
RELAY_TARGET,SHOPIFY_WEBHOOK_SECRETand/orWC_WEBHOOK_SECRETas variables. - Set the health check path to
/healthzand the restart policy to restart on failure. - Generate a domain, and use
https://<domain>/webhooks/shopifyor/webhooks/woocommerceas the webhook URL.
Railway's railway.json config file is marked deprecated in favor of its newer infrastructure-as-code setup, with existing files supported for legacy services until December 2026, so set these in the service settings rather than starting with that file.
8. Running it on a VPS
On a VPS, Docker Compose with Caddy in front gives you the same shape: automatic TLS, restart on failure, and the queue on a named volume. Caddy needs a DNS record pointing at the server before it can get a certificate.
# /opt/relay/compose.yaml on the VPS: Caddy gets the TLS certificate, the relay keeps its queue on a volume
services:
relay:
image: python:3.13-slim
command: python /app/relay.py
working_dir: /app
volumes: [./relay.py:/app/relay.py:ro, relay-data:/data]
env_file: .env # RELAY_TARGET, SHOPIFY_WEBHOOK_SECRET, WC_WEBHOOK_SECRET
ports: ["127.0.0.1:8080:8080"] # /status for a local monitor; the internet goes through Caddy
restart: unless-stopped
stop_grace_period: 20s # let an in-flight forward finish; anything unfinished is resent after restart
caddy:
image: caddy:2
command: caddy reverse-proxy --from hooks.example.com --to relay:8080
ports: ["80:80", "443:443"]
volumes: [caddy-data:/data]
restart: unless-stopped
volumes:
relay-data:
caddy-data:
What you take on compared with Railway: OS security updates, Docker updates, firewall rules (only 80, 443 and SSH open), backups of the volume, and noticing when the box itself goes down. That last one is the one people skip; an external monitor on /status covers it.
Vultr lists a 1 vCPU / 1 GB plan at $5 a month and 1 vCPU / 2 GB at $10.
DigitalOcean Basic Droplets start at $6 for 1 GB and $12 for 2 GB, and offer managed databases if the relay grows into something that needs Postgres. Either runs this relay and two or three similar services on the smallest plan with room to spare.
9. Our opinion: trade-offs
- Small, always-on services belong on Railway. A relay, a sync job and a cron task together still sit near the $5 minimum, with deploys from Git and no server to patch. The managed part is worth more than the few dollars saved.
- Heavy, steady workloads belong on a VPS. Usage pricing is great when you mostly idle and poor when you never do. At around $10 to $15 of steady Railway usage, price out a VPS.
- Durability is in the design, not the platform. Neither Railway nor a VPS saves an event you acknowledged before writing it down. Commit first, answer second, forward from a queue.
- WooCommerce needs a backstop either way. No retries plus auto-disable after 5 failures means any deploy gap or outage loses events. A periodic "orders modified since" sync is cheap insurance.
- Why Railway is our pick here. The workload is tiny and spiky, which is exactly where per-second usage billing wins, and the platform handles TLS, restarts, volumes and cron without a server to maintain.
Related: monitoring SSL, checkout latency and APIs, a Shopify-to-ActiveCampaign webhook relay and choosing a VPS for heavy workloads.
10. Tools
Top picks
| Tool | Role | Why it fits |
|---|---|---|
| Hosting for the relay, workers and cron jobs | Deploys from Git, per-second billing on measured usage, volumes, private networking and cron services. The $5 Hobby plan includes $5 of usage, which covers a relay like this one. | |
| VPS for steady or heavy workers | 1 vCPU / 1 GB at $5 a month and 2 GB at $10, in many regions; fixed price however busy the worker gets. | |
| VPS plus managed databases | Droplets from $6 a month, and managed Postgres or Redis when a queue in SQLite isn't enough any more. |
Alternatives: free and official tools
We don't earn anything from these links.
- Railway app sleeping and volumes: what triggers sleep, and the deploy and replica limits of volumes.
- Shopify HTTPS webhooks: signature, 5-second timeout and retry schedule.
- WooCommerce webhooks: signatures, delivery failures and the
woocommerce_max_webhook_delivery_failuresfilter. - Caddy reverse proxy: automatic HTTPS in front of the relay on a VPS.
- PM2: a process manager if you'd rather not use Docker;
pm2 start relay.py --interpreter python3pluspm2 startup.
11. Pitfalls
- Forwarding inside the webhook request. One slow downstream call turns into timeouts, Shopify retries and disabled WooCommerce webhooks.
- No volume on Railway. The SQLite file lives in the container and disappears on the next deploy, with whatever was queued.
- Serverless sleep on a receiver. A 502 on wake-up is a lost WooCommerce event.
- A health check that depends on downstream. Railway won't promote a deploy whose health check fails, so you can't ship the fix during the outage.
- Retrying forever. A payload the downstream rejects every time blocks nothing here, but it should end up somewhere a person looks: a dead letter and a 503 on
/status. - Trusting the store to resend. Shopify gives up after 4 hours; WooCommerce never resends. Keep a reconciliation job for anything that matters.
- Sizing a VPS for the average. Webhooks arrive in bursts during sales; the measured 45 forwards a second per worker is the number to check against your peak hour.
Write each webhook to disk before answering, answer in milliseconds, and forward from a queue with retries. That design used about 31 MB and 6 ms of CPU per webhook in our test, which on Railway is a $5 month with no server to run, the same price as a $5 Vultr or $6 DigitalOcean VPS you have to maintain. Move to a VPS when a worker keeps a CPU or gigabytes busy all day, keep Serverless sleep off for anything that receives webhooks, and add a reconciliation job for WooCommerce.