The webhook inbox for apps and agents
dotpipe receives and verifies webhook events, then banks them until your app or agent is ready to read them.
Providers send
Any service that sends webhooks.
dotpipe holds
One inbox for each source — this one is Stripe's. dotpipe answers 200 OK in milliseconds and keeps the event until you read it.
You read
GET /events?after=1040an agent, when it wakesGET /events/ssea service, in realtimeGET /events?after=1040a cron job, on schedulePoll when you wake, or stream in realtime over SSE or WebSocket. You host nothing.
Built for two kinds of readers
Applications and agents read the same inbox, for different reasons.
Applications
Point the provider at dotpipe instead of your service. Your service leaves the delivery path.
Deploys drop nothing
The provider talks to dotpipe, not to you. A deploy, an outage, or a restart does not lose events.
Bursts stay out of your database
Large synchronizations and provider retries wait in the inbox. Read them at your own pace.
One verification model
dotpipe checks signatures at the door. You do not maintain verification code for each provider.
History for recovery
Events stay readable for their full retention. Inspect and re-read anything, from any position.
Agents
An agent has no public URL, and it does not run all the time. It wakes up, reads what arrived, acts, and goes back to sleep. dotpipe holds the events in between.
No server, no public URL
Read the inbox from a laptop, a cron job, a Worker — anything that can make an HTTP request.
Wake up and catch up
Every read starts after your last position and returns newer events in order. Events wait in the inbox while you sleep.
// Runs every five minutes. No server. No public URL.
const since = await store.get("dotpipe-position")
const page = await fetch(
`https://app.dotpipe.io/api/sources/${sourceId}/events?after=${since}`,
{ headers: { Authorization: `Bearer ${apiKey}` } },
).then((response) => response.json())
for (const event of page.events) {
await agent.handle(event.payload)
}
await store.set("dotpipe-position", page.nextCursor)Read your inbox
Every read returns the events that arrived after your last position and the position to use next. Or open the realtime stream: it sends the retained events after your position, then each new event as it arrives. Events always arrive in order.
Poll for events
curl -G \
"https://app.dotpipe.io/api/sources/$SOURCE_ID/events" \
-H "Authorization: Bearer $DOTPIPE_API_KEY" \
--data-urlencode "after=42" \
--data-urlencode "limit=100"Stream in realtime
curl -N -G \
"https://app.dotpipe.io/api/sources/$SOURCE_ID/events/sse" \
-H "Authorization: Bearer $DOTPIPE_API_KEY" \
-H "Accept: text/event-stream" \
--data-urlencode "after=42"Each SSE message carries one complete event. -N turns off buffering, so events print as they arrive.
Poll for events
async function pollEvents(after: number) {
const response = await fetch(
`https://app.dotpipe.io/api/sources/${sourceId}/events?after=${after}&limit=100`,
{ headers: { Authorization: `Bearer ${apiKey}` } },
)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
return response.json()
}Stream in realtime
import { EventSource } from "eventsource"
const stream = new EventSource(
`https://app.dotpipe.io/api/sources/${sourceId}/events/sse?after=42`,
{
fetch: (url, init) =>
fetch(url, {
...init,
headers: { ...init?.headers, Authorization: `Bearer ${apiKey}` },
}),
},
)
stream.addEventListener("source-event", (message) => {
const event = JSON.parse(message.data)
console.log(event.cursor, event.payload)
})Each message id is the event cursor, so the standardLast-Event-ID reconnect resumes where you stopped.
Poll for events
import json
import os
from urllib.parse import urlencode
from urllib.request import Request, urlopen
def poll_events(after):
source_id = os.environ["SOURCE_ID"]
query = urlencode({"after": after, "limit": 100})
url = f"https://app.dotpipe.io/api/sources/{source_id}/events?{query}"
request = Request(url, headers={
"Authorization": f"Bearer {os.environ['DOTPIPE_API_KEY']}"
})
with urlopen(request) as response:
return json.load(response)Stream in realtime
source_id = os.environ["SOURCE_ID"]
request = Request(
f"https://app.dotpipe.io/api/sources/{source_id}/events/sse?after=42",
headers={
"Authorization": f"Bearer {os.environ['DOTPIPE_API_KEY']}",
"Accept": "text/event-stream",
},
)
with urlopen(request) as stream:
for line in stream:
line = line.decode()
if line.startswith("data:"):
event = json.loads(line[len("data:"):])
print(event["cursor"], event["payload"])Catch up and stream in realtime
import * as DotpipeClient from "@dotpipe/client"
import { Effect, Redacted, Stream } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
const program = Effect.gen(function* () {
const client = yield* DotpipeClient.make({
apiKey: Redacted.make(process.env.DOTPIPE_API_KEY!),
sourceId: process.env.SOURCE_ID!,
})
return yield* client.events(DotpipeClient.Cursor.make(42)).pipe(
Stream.tap((event) => Effect.log(event.cursor)),
Stream.runDrain,
)
}).pipe(Effect.provide(FetchHttpClient.layer))
Effect.runPromise(program)The details
- Retention
- One hour to thirty days, set for each source.
- Storage
- Up to 10 GB for each source.
- Signatures
- Checked for GitHub and Slack. Generic sources accept any sender that has the URL.
- Rotation
- Replace the URL or the provider secret without creating a new source.
- Realtime
- Stream complete events over SSE, or watch for notifications over WebSocket. Resume from any retained cursor.
- Re-reads
- Read anything still kept, as often as you want, from any position.
- Authentication
Authorization: Bearer <api-key>on every request. Keep the key on a server, an agent, or a job.- Page size
- Up to 1,000 events in one read.
- Sources
- Up to 100 for each workspace.
Start here