Inovacc Developer

API reference

Events

Publish events and deliver them to subscribers by webhook, pull or live stream, with dead letters and replay.

Base URL https://events.inovacc.dev

Overview

Events lets one part of your system announce that something happened, and lets every interested part hear about it, without the two knowing each other. A publisher sends an event such as order.paid to https://events.inovacc.dev; every subscription whose pattern matches the event's type receives it, by webhook to your HTTPS endpoint, by pull from your worker, or by live stream over a WebSocket. Events that cannot be delivered are kept as dead letters, which you can inspect and replay.

The problem it solves is reliable fan-out. Calling each interested service directly couples them, loses messages when one is down and retries badly. Events accepts the publish once, delivers it at least once to each subscription, retries failed deliveries with backoff, deduplicates repeated publishes, and keeps what it could not deliver.

Use Events to react to what happens in your system. Send an email when an order is paid, refresh a cache when a record changes, push a notification to a page. Keep the state itself in Data and the bytes in Files: an event should say what happened, not carry the object. For the full webhook receiver story, see the Webhooks guide.

Concepts

Workspace. Every route is under /v1/workspaces/{workspace}. The workspace in the path is a claim checked against your credential: an application key may name only its own workspace, and any other answers 403 forbidden, the same as one that does not exist.

Events and the envelope. You publish type (dotted lowercase words, such as invoice.created, up to 128 bytes), data (any JSON value) and an optional idempotency_key. Subscribers receive an envelope with event_id (evt_ and 26 characters, assigned by Inovacc), type, source (your application, set from the credential), time, tenant, idempotency_key and data.

Tiers. A publish may name a tier: basic (the default), reliable or advanced. The tier sets the largest data an event may carry: 16 KiB for basic and reliable, 32 KiB for advanced.

Subscriptions and patterns. A subscription lists 1 to 20 types patterns, each * (everything), an exact type, or a prefix ending in .* (order.* matches order.paid and order.item.added), and one delivery mode.

Delivery modes. webhook posts each event to your HTTPS URL, signed with the subscription's secret. pull keeps an inbox your code reads and acknowledges. websocket keeps the same inbox and also streams it live; pull keeps working beside the stream.

At least once. Each event reaches each matching subscription once under normal operation, but a retry or a lost acknowledgement can deliver it again. Receivers deduplicate by event_id.

Dead letters. A webhook delivery that keeps failing is moved to your workspace's dead letters, with the last error and the number of attempts, and kept 30 days.

How it works

Every call carries Authorization: Bearer <credential>. The credential is the tenant: organization, account and application come from it, and nothing in a body can name another. A secret key is for servers and may use every route. A publishable key (apb_...) may only open a stream, from an origin its application lists. An end user's token may use nothing. Each route needs its own permission, such as events:event.publish, events:subscription.write or events:dead.replay.

Publish. POST /events with {"event": {...}} or {"events": [...]} (1 to 100). The answer is 202 with accepted (each with its event_id and duplicate) and rejected (each with an index and a code); a bad event never blocks the others. Sending an idempotency_key already used in the workspace in the last 168 hours returns the original event_id with duplicate: true and delivers nothing new.

Webhook delivery. Inovacc posts the envelope to your URL with x-event-id, x-event-type, x-event-timestamp and x-event-signature: v1=<hex>, an HMAC-SHA256 of the timestamp and the raw body. Any 2xx within 10 seconds is a delivery; anything else is retried after 10 seconds, the delay doubling up to 10 minutes, until the retry cap of 5 is reached and the event becomes a dead letter. Redirects are never followed, and your URL must be public HTTPS on port 443.

Pull. POST /subscriptions/{id}/pull with max (1 to 100, default 10) and lease_seconds (5 to 300, default 30) returns the oldest messages, each with its delivery_count. A pulled message is hidden for its lease; acknowledge it with POST .../ack and the ids, or it returns when the lease ends.

Stream. GET /subscriptions/{id}/stream upgrades to a WebSocket with the subprotocol inovacc.v1; a browser passes its key as a second subprotocol, bearer.<key>. Each frame is one envelope; reply {"ack": [...]} to acknowledge. An unacknowledged frame is sent again after its lease.

Monitor. GET /stats returns published, completed, depth, oldest_pending_age_seconds, retries, retry_rate and dlq_inflow for the workspace. Every call is recorded in the Activity log. What is metered is the delivered event.

Get started

You need a secret key bound to your workspace with the events permissions (Authentication).

  1. Create a pull subscription. POST /v1/workspaces/{workspace}/subscriptions with {"types":["demo.*"],"delivery":{"mode":"pull"}} (see the samples). The answer is 201 with a subscription_id.
  2. Publish. POST .../events with {"event":{"type":"demo.hello","data":{"n":1}}}. The answer is 202 with one accepted entry and its event_id.
  3. Pull. POST .../subscriptions/{id}/pull with {}. Your event is in messages, with delivery_count 1.
  4. Acknowledge. POST .../subscriptions/{id}/ack with {"ids":["<event_id>"]} answers {"acked":1}; a second pull is empty.
  5. Add a webhook. Create a second subscription with {"mode":"webhook","url":"https://<your endpoint>"}, keep the signing_secret the answer shows once, and follow the Webhooks guide to verify deliveries.

Use cases

Order fulfilment. The checkout publishes order.paid with the order id in data and the payment id as idempotency_key. A webhook subscription for order.* reaches the warehouse system; a pull subscription feeds the invoicing job. A checkout retried by the customer publishes once.

Live updates in a page. A dashboard opens a stream on a websocket subscription for ticket.* with a publishable key from its own origin, shows each new ticket as the frame arrives, and acknowledges it.

Recovering from an outage. A partner's endpoint was down for an hour. Its deliveries ended in dead letters with last_error webhook_timeout. Once it is back, your team lists GET /dead and replays each event; it goes only to the subscriptions that never received it.

Limits and pricing

LimitValue
Events per publish100
Request body5 MiB
data per event16 KiB (basic, reliable), 32 KiB (advanced)
Deduplication window for idempotency_key168 hours
Subscriptions per workspace; patterns per subscription50; 20
Messages per pull; lease100; 5 to 300 seconds
Webhook response time10 seconds
Retriesfrom 10 seconds, doubling to 10 minutes; cap 5, then dead letter
Dead letters kept30 days
Stream frame from the client49,152 bytes
Rate600 calls per minute per credential, per location

Pricing: on request. The pricing unit is the delivered event.

Errors

StatusCodeWhat it means and what to do
400invalid_bodyThe body is malformed, or names source (it comes from your credential).
400invalid_queryOnly GET /dead takes a query (limit).
400invalid_subscription, invalid_delivery, invalid_limit, invalid_idFix the subscription, the delivery or the parameter.
400invalid_url, url_not_https, url_has_credentials, url_port_not_allowed, url_destination_blockedThe webhook URL must be public HTTPS on port 443, without credentials.
401invalid_credentialsSend a valid credential.
403forbidden, account_requiredMissing permission or wrong workspace; use an application key.
403publishable_key_not_allowed, origin_not_allowed, secret_key_in_browserA publishable key may only stream, from a listed origin; keep secret keys on servers.
404subscription_not_found, dead_event_not_foundIt does not exist in your workspace.
409wrong_delivery_mode, too_many_subscriptions, secret_not_managedThe operation does not fit the delivery mode, or you are at 50 subscriptions.
413payload_too_largeThe body is over 5 MiB.
426upgrade_requiredThe stream route needs a WebSocket upgrade.
429rate_limited, too_many_streamsSlow down; close sockets you do not need.
503service_unavailable, events_disabled, signing_unavailableRetry later; a publish retried with the same keys reuses its ids.

A rejected event inside a 202 carries its own code: invalid_event, unknown_field, invalid_type, invalid_idempotency_key, payload_too_large or data_required.

Best practices

  • Send an idempotency_key derived from what happened (the payment id, the record version), so a retried publish is not a second event.
  • Deduplicate by event_id in every receiver: delivery is at least once.
  • Put ids in data, not objects: fetch the current state from Data when you handle the event.
  • Answer webhooks fast with a 2xx and do the work afterwards; 10 seconds is the limit.
  • Verify every webhook signature before parsing the body (Webhooks guide).
  • Acknowledge pulled messages after processing, and choose a lease longer than your processing time.
  • Watch depth and dlq_inflow in /stats, and replay dead letters once the cause is fixed.

Authentication

Every call carries your key; your organization comes from it. See the authentication guide.

HeaderAuthorizationBearer <API key>

Endpoints

POST /v1/workspaces/{workspace_id}/events

GET /v1/workspaces/{workspace_id}/subscriptions

POST /v1/workspaces/{workspace_id}/subscriptions

DELETE /v1/workspaces/{workspace_id}/subscriptions/{subscription_id}

POST /v1/workspaces/{workspace_id}/subscriptions/{subscription_id}/rotate-secret

POST /v1/workspaces/{workspace_id}/subscriptions/{subscription_id}/pull

POST /v1/workspaces/{workspace_id}/subscriptions/{subscription_id}/ack

GET /v1/workspaces/{workspace_id}/subscriptions/{subscription_id}/stream

GET /v1/workspaces/{workspace_id}/dead

POST /v1/workspaces/{workspace_id}/dead/{event_id}/replay

GET /v1/workspaces/{workspace_id}/stats

Example

Language

⋮
POST /v1/workspaces/{workspace_id}/eventsExample

cURL

curl -X POST "https://events.inovacc.dev/v1/workspaces/{workspace_id}/events" \
  -H "Authorization: Bearer $INOVACC_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "event": {
    "type": "order.paid",
    "idempotency_key": "pay_123",
    "data": {
      "order_id": "ord_42"
    }
  }
}'

TypeScript

const body: Record<string, unknown> = {
  "event": {
    "type": "order.paid",
    "idempotency_key": "pay_123",
    "data": {
      "order_id": "ord_42"
    }
  }
};

const url = "https://events.inovacc.dev/v1/workspaces/{workspace_id}/events";

const response = await fetch(url, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.INOVACC_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify(body),
});

console.log(response.status, await response.text());

Python

import json
import os
import urllib.request

body = {
    "event": {
        "type": "order.paid",
        "idempotency_key": "pay_123",
        "data": {
            "order_id": "ord_42",
        },
    },
}

request = urllib.request.Request(
    "https://events.inovacc.dev/v1/workspaces/{workspace_id}/events",
    data=json.dumps(body).encode(),
    method="POST",
    headers={
        "User-Agent": "inovacc-python-sample",
        "Authorization": "Bearer " + os.environ["INOVACC_API_KEY"],
        "Content-Type": "application/json",
    },
)

with urllib.request.urlopen(request) as response:
    print(response.status, response.read().decode())

Go

package main

import (
	"fmt"
	"io"
	"net/http"
	"os"
	"strings"
)

const url = "https://events.inovacc.dev/v1/workspaces/{workspace_id}/events"

const body = `{
  "event": {
    "type": "order.paid",
    "idempotency_key": "pay_123",
    "data": {
      "order_id": "ord_42"
    }
  }
}`

func main() {
	req, err := http.NewRequest("POST", url, strings.NewReader(body))
	if err != nil {
		panic(err)
	}
	req.Header.Set("Authorization", "Bearer "+os.Getenv("INOVACC_API_KEY"))
	req.Header.Set("Content-Type", "application/json")

	res, err := http.DefaultClient.Do(req)
	if err != nil {
		panic(err)
	}
	defer res.Body.Close()

	out, err := io.ReadAll(res.Body)
	if err != nil {
		panic(err)
	}
	fmt.Println(res.Status, string(out))
}

Rust

// Cargo.toml: reqwest = { version = "0.12", features = ["blocking", "json"] }
// Cargo.toml: serde_json = "1"

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let api_key = std::env::var("INOVACC_API_KEY")?;
    let body = serde_json::json!({
        "event": {
            "type": "order.paid",
            "idempotency_key": "pay_123",
            "data": {
                "order_id": "ord_42"
            }
        }
    });
    let response = reqwest::blocking::Client::new()
        .post("https://events.inovacc.dev/v1/workspaces/{workspace_id}/events")
        .bearer_auth(api_key)
        .json(&body)
        .send()?;
    println!("{} {}", response.status(), response.text()?);
    Ok(())
}

JavaScript

const body = {
  "event": {
    "type": "order.paid",
    "idempotency_key": "pay_123",
    "data": {
      "order_id": "ord_42"
    }
  }
};

const url = "https://events.inovacc.dev/v1/workspaces/{workspace_id}/events";

const response = await fetch(url, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.INOVACC_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify(body),
});

console.log(response.status, await response.text());

PHP

<?php

$body = <<<'JSON'
{
  "event": {
    "type": "order.paid",
    "idempotency_key": "pay_123",
    "data": {
      "order_id": "ord_42"
    }
  }
}
JSON;

$curl = curl_init('https://events.inovacc.dev/v1/workspaces/{workspace_id}/events');
curl_setopt_array($curl, [
    CURLOPT_CUSTOMREQUEST => 'POST',
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer ' . getenv('INOVACC_API_KEY'),
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => $body,
]);

$response = curl_exec($curl);
echo curl_getinfo($curl, CURLINFO_HTTP_CODE), ' ', $response, "\n";
curl_close($curl);

Ruby

require "net/http"
require "uri"

uri = URI('https://events.inovacc.dev/v1/workspaces/{workspace_id}/events')
body = <<~'JSON'
{
  "event": {
    "type": "order.paid",
    "idempotency_key": "pay_123",
    "data": {
      "order_id": "ord_42"
    }
  }
}
JSON

request = Net::HTTP::Post.new(uri)
request["Authorization"] = "Bearer #{ENV.fetch('INOVACC_API_KEY')}"
request["Content-Type"] = "application/json"
request.body = body

response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
  http.request(request)
end

puts "#{response.code} #{response.body}"

Java

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

public class Main {
    public static void main(String[] args) throws Exception {
        String apiKey = System.getenv("INOVACC_API_KEY");
        String body = "{" +
                "\"event\": {" +
                "\"type\": \"order.paid\"," +
                "\"idempotency_key\": \"pay_123\"," +
                "\"data\": {" +
                "\"order_id\": \"ord_42\"" +
                "}" +
                "}" +
                "}";

        HttpRequest request = HttpRequest.newBuilder()
                .uri(URI.create("https://events.inovacc.dev/v1/workspaces/{workspace_id}/events"))
                .header("Authorization", "Bearer " + apiKey)
                .header("Content-Type", "application/json")
                .method("POST", HttpRequest.BodyPublishers.ofString(body))
                .build();

        HttpResponse<String> response = HttpClient.newHttpClient()
                .send(request, HttpResponse.BodyHandlers.ofString());
        System.out.println(response.statusCode() + " " + response.body());
    }
}

C#

using System.Text;

var body = "{" +
    "\"event\": {" +
    "\"type\": \"order.paid\"," +
    "\"idempotency_key\": \"pay_123\"," +
    "\"data\": {" +
    "\"order_id\": \"ord_42\"" +
    "}" +
    "}" +
    "}";

var url = "https://events.inovacc.dev/v1/workspaces/{workspace_id}/events";
var apiKey = Environment.GetEnvironmentVariable("INOVACC_API_KEY");

using var client = new HttpClient();
using var request = new HttpRequestMessage(HttpMethod.Post, url);
request.Headers.Add("Authorization", $"Bearer {apiKey}");
request.Content = new StringContent(body, Encoding.UTF8, "application/json");

using var response = await client.SendAsync(request);
Console.WriteLine($"{(int)response.StatusCode} {await response.Content.ReadAsStringAsync()}");

Kotlin

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

fun main() {
    val apiKey = System.getenv("INOVACC_API_KEY")
    val body = "{" +
        "\"event\": {" +
        "\"type\": \"order.paid\"," +
        "\"idempotency_key\": \"pay_123\"," +
        "\"data\": {" +
        "\"order_id\": \"ord_42\"" +
        "}" +
        "}" +
        "}"

    val request = HttpRequest.newBuilder()
        .uri(URI.create("https://events.inovacc.dev/v1/workspaces/{workspace_id}/events"))
        .header("Authorization", "Bearer " + apiKey)
        .header("Content-Type", "application/json")
        .method("POST", HttpRequest.BodyPublishers.ofString(body))
        .build()

    val response = HttpClient.newHttpClient()
        .send(request, HttpResponse.BodyHandlers.ofString())
    println("${response.statusCode()} ${response.body()}")
}

Swift

import Foundation
#if canImport(FoundationNetworking)
import FoundationNetworking
#endif

let body = #"""
{
  "event": {
    "type": "order.paid",
    "idempotency_key": "pay_123",
    "data": {
      "order_id": "ord_42"
    }
  }
}
"""#

let apiKey = ProcessInfo.processInfo.environment["INOVACC_API_KEY"] ?? ""

let url = URL(string: "https://events.inovacc.dev/v1/workspaces/{workspace_id}/events")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = Data(body.utf8)

let (data, response) = try await URLSession.shared.data(for: request)
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
print(status, String(decoding: data, as: UTF8.self))
Source details

Catalogue entry

Id
identity/component-taxonomy/capabilities#events
Repository
identity
Path
contracts/component-taxonomy/catalog/capabilities.json
Commit
08ef3cd75d82

Example

Id
identity/component-taxonomy/quickstart#events
Repository
identity
Path
contracts/component-taxonomy/catalog/capabilities.json
Commit
08ef3cd75d82
Updated 2026-10-10.