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

GitHub push
Slack message
Stripe invoice.paid
Apollo contact.created
Vector visitor.identified

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 wakes
GET /events/ssea service, in realtime
GET /events?after=1040a cron job, on schedule

Poll 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)
Further reading: why you should rethink your webhook strategy

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

Give your provider a URL that is always up.

Open dotpipe