Schedule Webhooks Without a Job Queue in 5 Minutes
You need to send a payment reminder in 3 days. Or a trial expiration notice in 7 days. Or a scheduled notification your user configured.
The standard approach: Redis, a job queue library, worker processes, monitoring dashboards, and manual intervention when jobs get stuck.
For a single delayed HTTP call.
The 5-Minute Version
Relay handles webhook scheduling with one API call.
Step 1: Schedule a webhook
curl -X POST https://api.solenoid.systems/v1/relay \
-H "Authorization: Bearer sm_your_api_key" \
-H "Content-Type: application/json" \
-d '{
"target_url": "https://your-app.com/webhooks",
"method": "POST",
"delay": "3d",
"payload": {
"event": "payment_reminder",
"user_id": "user_123",
"amount_due": 99
}
}'
Response:
{
"relay_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"scheduled_for": 1706553456789,
"status": "scheduled"
}
That’s it. In 3 days, your webhook fires automatically.
Step 2: It fires automatically
When the delay expires, Relay POSTs your payload to the target URL:
POST /webhooks HTTP/1.1
Host: your-app.com
Content-Type: application/json
X-Solenoid-Signature: abc123def456...
X-Solenoid-Timestamp: 1706553456789
{
"event": "payment_reminder",
"user_id": "user_123",
"amount_due": 99
}
Step 3: Automatic retries
If your endpoint returns 5xx or has network issues, Relay retries up to 3 times with exponential backoff. 2xx means success. 4xx means permanent failure, no retry.
No queue monitoring. No stuck job debugging. Just scheduled delivery with built-in reliability.
Handling the Edge Cases
Dynamic delays: Use TypeScript to compute delays from user input:
async function scheduleTrialExpiration(userId: string, planDays: number) {
const delay = `${planDays}d`
const response = await fetch('https://api.solenoid.systems/v1/relay', {
method: 'POST',
headers: {
'Authorization': 'Bearer sm_your_api_key',
'Content-Type': 'application/json'
},
body: JSON.stringify({
target_url: 'https://your-app.com/webhooks/trial-expiring',
method: 'POST',
delay,
payload: { user_id: userId, plan_days: planDays }
})
})
const { relay_id } = await response.json()
// Store relay_id in your database for tracking
await db.users.update(userId, { trial_expiration_relay_id: relay_id })
}
Cancellation: User upgrades before their trial expires? Just don’t handle the webhook. There’s no cancel endpoint because webhooks that fail with 4xx aren’t retried.
Return 400 Bad Request from your webhook handler and Relay stops retrying.
Custom headers: Include authentication or routing metadata:
curl -X POST https://api.solenoid.systems/v1/relay \
-H "Authorization: Bearer sm_your_api_key" \
-d '{
"target_url": "https://your-app.com/webhooks",
"method": "POST",
"delay": "5m",
"payload": {"event": "reminder"},
"headers": {
"X-Event-Type": "reminder",
"X-Internal-Auth": "your_webhook_secret"
}
}'
Signature Verification
Relay signs every webhook. Verify it before processing:
import crypto from 'crypto'
function verifyRelay(body: string, signature: string, timestamp: string) {
const signed = timestamp + body
const computed = crypto
.createHmac('sha256', process.env.SOLENOID_API_KEY)
.update(signed)
.digest('hex')
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(computed)
)
}
// In your webhook handler
const signature = req.headers['x-solenoid-signature']
const timestamp = req.headers['x-solenoid-timestamp']
const body = JSON.stringify(req.body)
if (!verifyRelay(body, signature, timestamp)) {
return res.status(401).json({ error: 'Invalid signature' })
}
// Process webhook...
Real-World Patterns
Trial expiration workflow:
// Day 0: User signs up
await scheduleTrialExpiration(userId, 14)
// Day 11: Send "3 days left" reminder
await fetch('https://api.solenoid.systems/v1/relay', {
method: 'POST',
headers: { 'Authorization': 'Bearer sm_your_api_key', 'Content-Type': 'application/json' },
body: JSON.stringify({
target_url: 'https://your-app.com/webhooks/trial-reminder',
method: 'POST',
delay: '11d',
payload: { user_id: userId, days_remaining: 3 }
})
})
// Your webhook handler
app.post('/webhooks/trial-reminder', async (req, res) => {
const { user_id, days_remaining } = req.body
const user = await db.users.findById(user_id)
if (user.plan === 'free') {
await sendEmail(user.email, `Your trial expires in ${days_remaining} days`)
}
res.json({ received: true })
})
Payment reminders:
async function schedulePaymentReminder(invoiceId: string, dueDate: Date) {
const now = Date.now()
const due = dueDate.getTime()
const delayMs = due - now
const delayDays = Math.floor(delayMs / (1000 * 60 * 60 * 24))
await fetch('https://api.solenoid.systems/v1/relay', {
method: 'POST',
headers: { 'Authorization': 'Bearer sm_your_api_key', 'Content-Type': 'application/json' },
body: JSON.stringify({
target_url: 'https://your-app.com/webhooks/payment-due',
method: 'POST',
delay: `${delayDays}d`,
payload: { invoice_id: invoiceId }
})
})
}
Why Not Build This Yourself?
You could build webhook scheduling. Store jobs in Postgres with a scheduled_for timestamp. Poll every minute. Send the ones that are due. Handle retries with exponential backoff.
But that’s infrastructure. Polling queries. Connection pooling. Clock skew handling. Retry state machines. Job deduplication. Monitoring dashboards.
When your scheduler crashes at 2 AM, your scheduled webhooks don’t fire. Users don’t get trial expiration notices. Payment reminders vanish.
Relay runs on Cloudflare Workers with Durable Objects for reliable scheduling. Global edge deployment. Automatic retries. No servers to monitor.
You schedule webhooks. Cloudflare ensures they fire.
Try It Now
Free tier: 10,000 calls to prototype. Pro tier ($49.99/mo) and above: unlimited Relay usage.
# Schedule a webhook 5 minutes from now
curl -X POST https://api.solenoid.systems/v1/relay \
-H "Authorization: Bearer sm_your_api_key" \
-d '{
"target_url": "https://webhook.site/your-unique-id",
"method": "POST",
"delay": "5m",
"payload": {"test": "hello"}
}'
Visit webhook.site, grab a test URL, and watch your webhook arrive exactly 5 minutes later.
Your job queue replacement, in one API call.