recalled.dev
Getting started

Use from any language

The REST API is plain HTTPS + JSON. Any language with an HTTP client can send events. Below is the same POST /v1/events call, written idiomatically in a handful of languages. Drop in your API key, run, done.

Every example targets:

text
POST https://api.recalled.dev/v1/events
Authorization: Bearer $RECALLED_API_KEY
Content-Type: application/json

With the body:

json
{
  "action": "invoice.deleted",
  "actor": { "id": "user_123", "email": "alice@example.com" },
  "organization": "org_acme",
  "targets": [{ "type": "invoice", "id": "inv_42" }],
  "metadata": { "reason": "duplicate" }
}

curl

bash
curl -X POST https://api.recalled.dev/v1/events \
  -H "Authorization: Bearer $RECALLED_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "action": "invoice.deleted",
    "actor": { "id": "user_123", "email": "alice@example.com" },
    "organization": "org_acme",
    "targets": [{ "type": "invoice", "id": "inv_42" }],
    "metadata": { "reason": "duplicate" }
  }'

Node.js (fetch, no SDK)

If you don't want the SDK and prefer raw fetch:

js
const response = await fetch("https://api.recalled.dev/v1/events", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.RECALLED_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    action: "invoice.deleted",
    actor: { id: "user_123", email: "alice@example.com" },
    organization: "org_acme",
    targets: [{ type: "invoice", id: "inv_42" }],
    metadata: { reason: "duplicate" },
  }),
});

if (!response.ok) throw new Error(await response.text());
const { data: event } = await response.json();

Python (requests)

python
import os
import requests

response = requests.post(
    "https://api.recalled.dev/v1/events",
    headers={
        "Authorization": f"Bearer {os.environ['RECALLED_API_KEY']}",
        "Content-Type": "application/json",
    },
    json={
        "action": "invoice.deleted",
        "actor": {"id": "user_123", "email": "alice@example.com"},
        "organization": "org_acme",
        "targets": [{"type": "invoice", "id": "inv_42"}],
        "metadata": {"reason": "duplicate"},
    },
    timeout=10,
)
response.raise_for_status()
event = response.json()["data"]

Go (net/http)

go
package main

import (
    "bytes"
    "encoding/json"
    "net/http"
    "os"
    "time"
)

func main() {
    body, _ := json.Marshal(map[string]any{
        "action":       "invoice.deleted",
        "actor":        map[string]any{"id": "user_123", "email": "alice@example.com"},
        "organization": "org_acme",
        "targets":      []map[string]any{{"type": "invoice", "id": "inv_42"}},
        "metadata":     map[string]any{"reason": "duplicate"},
    })

    req, _ := http.NewRequest("POST", "https://api.recalled.dev/v1/events", bytes.NewReader(body))
    req.Header.Set("Authorization", "Bearer "+os.Getenv("RECALLED_API_KEY"))
    req.Header.Set("Content-Type", "application/json")

    client := &http.Client{Timeout: 10 * time.Second}
    resp, err := client.Do(req)
    if err != nil {
        panic(err)
    }
    defer resp.Body.Close()
}

PHP (curl)

php
<?php
$body = json_encode([
    "action" => "invoice.deleted",
    "actor" => ["id" => "user_123", "email" => "alice@example.com"],
    "organization" => "org_acme",
    "targets" => [["type" => "invoice", "id" => "inv_42"]],
    "metadata" => ["reason" => "duplicate"],
]);

$ch = curl_init("https://api.recalled.dev/v1/events");
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_POSTFIELDS => $body,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer " . getenv("RECALLED_API_KEY"),
        "Content-Type: application/json",
    ],
]);
$response = curl_exec($ch);
curl_close($ch);

Ruby (Net::HTTP)

ruby
require "net/http"
require "json"
require "uri"

uri = URI("https://api.recalled.dev/v1/events")
request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer #{ENV['RECALLED_API_KEY']}"
request["Content-Type"] = "application/json"
request.body = {
  action: "invoice.deleted",
  actor: { id: "user_123", email: "alice@example.com" },
  organization: "org_acme",
  targets: [{ type: "invoice", id: "inv_42" }],
  metadata: { reason: "duplicate" },
}.to_json

response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
  http.request(request)
end

Java (java.net.http)

java
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

String body = """
{
  "action": "invoice.deleted",
  "actor": { "id": "user_123", "email": "alice@example.com" },
  "organization": "org_acme",
  "targets": [{ "type": "invoice", "id": "inv_42" }],
  "metadata": { "reason": "duplicate" }
}
""";

HttpRequest request = HttpRequest.newBuilder()
    .uri(URI.create("https://api.recalled.dev/v1/events"))
    .header("Authorization", "Bearer " + System.getenv("RECALLED_API_KEY"))
    .header("Content-Type", "application/json")
    .POST(HttpRequest.BodyPublishers.ofString(body))
    .build();

HttpResponse<String> response = HttpClient.newHttpClient()
    .send(request, HttpResponse.BodyHandlers.ofString());

Rust (reqwest)

rust
use serde_json::json;

let client = reqwest::Client::new();
let response = client
    .post("https://api.recalled.dev/v1/events")
    .header(
        "Authorization",
        format!("Bearer {}", std::env::var("RECALLED_API_KEY")?),
    )
    .header("Content-Type", "application/json")
    .json(&json!({
        "action": "invoice.deleted",
        "actor": { "id": "user_123", "email": "alice@example.com" },
        "organization": "org_acme",
        "targets": [{ "type": "invoice", "id": "inv_42" }],
        "metadata": { "reason": "duplicate" }
    }))
    .send()
    .await?
    .error_for_status()?;

Error handling

Every non-2xx response has the same shape:

json
{
  "error": {
    "code": "PLAN_LIMIT_REACHED",
    "message": "Monthly event quota exceeded",
    "details": { "limit": 5000 }
  }
}
  • 400: VALIDATION_ERROR, body failed schema (fields listed in details)
  • 401: UNAUTHORIZED, INVALID_API_KEY or REVOKED_API_KEY, key missing, wrong or revoked
  • 403: FORBIDDEN, key is valid but the feature is plan-gated
  • 429: RATE_LIMITED (check RateLimit-Reset) or PLAN_LIMIT_REACHED (monthly quota)
  • 5xx: retry with backoff

Full list in Error codes.

Retry strategy

Retry on 408, 429, 502, 503, 504 with exponential backoff (1s to 10min). Do not retry on 400, 401, 403, 404, those are permanent.

The npm SDK implements this for you, including a 24h in-memory queue for emit(). If you're on Node, use the SDK. Otherwise, wrap your HTTP client in a retry loop.