SLA · Privacy · Terms

Raposa Aval — API

v0.1 · base URL https://dcescrypt.com/api · updated 2026-08-24

One API call pauses a high-stakes action until an authorized human approves or rejects it. Every decision is sealed into a hash-chained audit log, and your systems are notified by a signed webhook.

Authentication

All endpoints take a bearer token. Two roles exist:

RoleCan do
clientcreate an approval, read its status
operatoreverything a client can, plus decide, list pending, verify the audit chain
Authorization: Bearer <CLIENT_API_KEY>

Keys are issued by DC ESCRYPT and shown once — only their SHA-256 hashes are stored, and revocation takes effect immediately. Request one at raposa.group/start, or write to contact@raposa.group.

Create an approval

POST /api/v1/approvals

{
  "action": "refund",                       // ≤ 200 chars, required
  "context": "ticket 9182, EUR 240",        // ≤ 4000 chars, required
  "risk": "low | medium | high",            // required
  "requested_by": "agent-billing-1",        // ≤ 200 chars, required
  "expires_in_sec": 86400,                  // 60 … 2592000, default 86400
  "webhook_url": "https://you.example/hook", // optional, https only
  "remind_after_sec": 1800,                 // optional: remind approvers if still pending
  "escalate_to": "cfo",                     // optional, needs remind_after_sec: pull in a second approver
  "approvers": ["finance", "cfo"],          // optional: group tags and/or console names who may decide
  "required": 2                             // optional, default 1: how many approve votes it takes
}

→ 200 {"id": "0f2c…", "status": "pending"}

Unknown fields are rejected — the schema is strict. Rate limit: 60 requests per minute per key; over the limit the API answers 429.

Read status

GET /api/v1/approvals/{id}

→ 200 {
  "id": "0f2c…",
  "action": "refund",
  "context": "ticket 9182, EUR 240",
  "risk": "low",
  "requested_by": "agent-billing-1",
  "status": "pending | approved | rejected | expired",
  "created_at": "2026-08-24T09:12:03.114Z",
  "expires_at": "2026-08-25T09:12:03.114Z",
  "decided_by": "operator-anna",
  "decided_at": "2026-08-24T09:14:41.882Z",
  "decision_comment": "confirmed with the customer",
  "webhook_url": "https://you.example/hook"
}

Expiry is evaluated on read: a pending approval past expires_at flips to expired, and that transition is audited and delivered by webhook like any other decision.

Decide (operator)

POST /api/v1/approvals/{id}/decision
Authorization: Bearer <OPERATOR_KEY>

{"decision": "approve | reject", "comment": "optional, ≤ 2000 chars"}

A second decision on the same approval returns 409. Humans decide in the console at /api/panel — your own approver with a scoped login (see Who approves), or DC ESCRYPT staff on request.

List approvals

GET /api/v1/approvals?status=pending&risk=high&limit=50&offset=0

→ 200 {"approvals": [ … ], "total": 128, "limit": 50, "offset": 0}

status is one of pending, approved, rejected, expired or all; risk is optional; limit is capped at 200. A client key lists its own approvals and total counts only those; an operator key lists everything in its scope. Approvals past their expiry are flipped to expired as the list is read, so a listing never shows a stale pending row.

Verify the audit chain (operator)

GET /api/v1/audit/verify
→ 200 {"ok": true, "entries": 128}
→ 200 {"ok": false, "entries": 41, "broken_id": 42}

Each entry is sha256(prev_hash + ts + event_type + approval_id + actor + payload). Any edit, insert or delete breaks verification from that point on, so tampering is detectable rather than merely discouraged. Secrets never enter the payload.

Export your audit trail

GET /api/v1/audit/export
Authorization: Bearer <CLIENT_API_KEY>

→ 200 {
  "count": 2,
  "entries": [
    {"id": 128, "ts": "2026-08-24T09:12:03.114Z", "event_type": "approval_created",
     "approval_id": "0f2c…", "actor": "acme", "payload": {…},
     "prev_hash": "9f1c…", "hash": "4ad0…", "self_hash_ok": true}
  ]
}

Returns the audit entries for your approvals only, each with its position in the global chain and a recomputed self_hash_ok. You cannot recompute the entire chain from this — that would require other customers' payloads, which isolation forbids; full-chain verification is the operator endpoint /v1/audit/verify.

Webhooks

If you pass webhook_url, the decision is POSTed to it:

X-Raposa-Event: approval.decided
X-Raposa-Delivery: <uuid, same across retries>
X-Raposa-Timestamp: 2026-08-24T09:14:41.913Z
X-Raposa-Signature: sha256=<hmac>

{"approval_id":"0f2c…","status":"approved","decided_by":"operator-anna",
 "decided_at":"2026-08-24T09:14:41.882Z","comment":"confirmed with the customer"}

Verify the signature against the raw request body using your CLIENT_WEBHOOK_SECRET:

expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
hmac.compare_digest(expected, request.headers["X-Raposa-Signature"])

Retries: immediately, then after 5 s, 30 s and 120 s — four attempts total. Success is any 2xx; redirects count as failure and are not followed; one attempt times out after 10 s. Every attempt is recorded in the audit log.

Your endpoint must be public https. URLs resolving to loopback, private, link-local or reserved addresses are rejected at creation time with 422 — this protects our infrastructure and yours.

Rotate your webhook secret

POST /api/v1/webhook-secret/rotate
Authorization: Bearer <CLIENT_API_KEY>

→ 200 {"webhook_secret": "…"}   // shown once

The old secret stops working immediately. Deliveries already in flight were signed with it, so accept both values for a few minutes, or rotate during a quiet window.

Errors

CodeWhen
401missing, malformed, unknown or revoked key
403client key used on an operator-only endpoint
404no approval with that id
409approval already approved, rejected or expired
422schema violation, or an unusable webhook_url
429more than 60 creations per minute for one key

Minimal client

import requests, time

BASE = "https://dcescrypt.com/api"
H = {"Authorization": "Bearer " + CLIENT_API_KEY}

r = requests.post(f"{BASE}/v1/approvals", headers=H, json={
    "action": "payout", "context": "invoice 7, EUR 1200",
    "risk": "high", "requested_by": "agent-finance",
    "webhook_url": "https://you.example/hooks/raposa",
}).json()

while True:                                  # or just wait for the webhook
    s = requests.get(f"{BASE}/v1/approvals/{r['id']}", headers=H).json()
    if s["status"] != "pending":
        break
    time.sleep(5)

if s["status"] == "approved":
    do_the_payout()

Data and retention

Servers and database are in Germany; transactional email goes through the EU region. What we store, on what legal basis and for how long is set out in the Privacy Policy. Do not put special-category personal data into context.

Using it from an agent framework

Raposa is one HTTP call, so it drops into any framework. The pattern is always the same: the tool asks for approval as its first line, waits, and only then does the thing.

@function_tool
def issue_refund(order_id: str, amount_eur: float, reason: str) -> str:
    """Refund a customer. Requires human approval before any money moves."""
    try:
        decision = gate.guard(
            action="issue_refund",
            context=f"Order {order_id}, EUR {amount_eur:.2f}. Reason: {reason}",
            risk="high" if amount_eur >= 100 else "medium",
            requested_by="support-agent",
        )
    except Denied as exc:
        return f"Refund not issued — a human declined: {exc}"
    except TimedOut as exc:
        return f"Refund not issued — nobody answered in time: {exc}"

    # ... the real refund call goes here, and only here ...
    return f"Refund issued. Approved by {decision['decided_by']} at {decision['decided_at']}."

The model cannot skip the gate, because the gate is not something the model decides — it is the first line of the function. And the record of that decision does not live in the agent runtime: it is signed, chained, and still there after you switch frameworks.

The full example for the OpenAI Agents SDK is here: openai_agents_sdk.py (146 lines, MIT, no framework lock-in), and its Raposa half runs in our test suite on every change — including the cases that matter most: a rejection stops the action, an expired request stops the action, and silence is not consent.

Your account

GET /api/v1/me with your key returns who you are and where you stand this month: plan, limit, used, remaining, resets_at. The same view, plus your approvals, audit export and secret rotation, is at dcescrypt.com/api/portal — sign in with the key; it stays in the browser tab and is never stored.

Lost or leaked key? Rotate it yourself: Rotate API key on the account page, or POST /api/v1/me/rotate-key. You get a new key once; the account, its history, approvers, webhook secret and Slack connection stay; the previous key keeps working for 60 minutes so running agents can switch, then stops. Your key never arrives by email: the welcome mail carries a one-time link that reveals it on a page, and we keep only a hash.

Who approves

Your agents create approvals; a client key cannot decide them — a customer must not be able to approve its own request. The person on your team who holds the approve button gets a console login at /api/panel, scoped to your approvals only, with optional TOTP. You create these yourself on your account page (/api/portal → Your approvers) or with the API below; the password is shown once. Their decisions are recorded under that name (decided_by: "approver:<name>") in the approval, the webhook and your audit export. Decisions taken by DC ESCRYPT staff appear as operator.

Managing approvers by API. GET /v1/approvers lists yours (user, email, groups, slack_linked, telegram_linked); POST /v1/approvers with {"user": "anna", "email": "anna@acme.io", "groups": ["finance"]} creates one and returns the password once — if your Slack workspace is connected, the approver is looked up by that email and linked automatically (slack_linked); PATCH /v1/approvers/anna changes email, groups or an explicit slack_user_id, and {"telegram_link": true} returns a one-time t.me link the approver taps to bind Telegram; POST /v1/approvers/anna/password issues a new password; DELETE /v1/approvers/anna revokes the login. Names are 2–40 characters of a-z 0-9 . _ -; a taken name answers 409, someone else's 404. Every change is in your audit export under customer:<key name>.

Approver groups and N-of-M. Each console login can carry group tags (we set them for you: finance, oncall, legal…). Name who may decide with approvers — group tags and/or console names — and how many approve votes it takes with required. Only those people receive the request; anyone else answers 403. Each vote is audited as approval_vote; when the count reaches required the approval is approved with every signer's name in decided_by (approver:anna,approver:marc); one reject rejects at once. Reads show votes: {approve, reject, required} and voted_by. Names that do not exist on your account, or a quorum larger than the people who could reach it, are refused at creation with 422.

Reminders and escalation. Set remind_after_sec on the request and, if nobody has decided by then, every approver gets a reminder with fresh links; add escalate_to (the console name of a second person on your team) and that person is pulled in at the same moment, with their own links and their own name on the decision. A reminder fires once, only while the request is pending, and is recorded as approval_reminded in your audit export. The schedule is stored with the request, so a restart on our side cannot lose it. Reads of the approval show remind_at and reminded_at.

Approve from email. If the approver has an email address on file, every new approval sends them a message with two signed links, Approve and Reject. Opening a link only shows the request — mail scanners and previews cannot decide anything; the decision happens when the person presses the button on that page. Links expire with the approval, a tampered link answers 403, an expired one 410, a second decision 409. The decision is recorded under the approver's name exactly like one taken in the console.

Approve from Telegram. Ask us for a link code for the approver; they send /start <code> to the Raposa bot once (the code lives 15 minutes and works once). From then on every new approval arrives as a card with Approve and Reject buttons, and so do reminders and escalations. A press decides under the approver's name; a second press answers "already decided", an expired request turns the card into Expired and decides nothing. Updates reach us only over a webhook secured with a secret Telegram echoes back — anything else is refused with 403.

Approve from Slack. Give us the approver's Slack member ID (profile → Copy member ID) and the Raposa Aval app sends them a direct message with the same two buttons; reminders and escalations follow the same path. Every interaction is verified against Slack's request signature and a five-minute timestamp window before it is even parsed (403 otherwise). The pressed message is replaced with the outcome and the approver's name; the same N-of-M and expiry rules apply as everywhere else. To receive these in your own workspace, press Add to Slack on your account page (/api/portal): Slack asks you to authorise the Raposa Aval app, and from then on messages to your approvers are sent from inside your workspace with your workspace's token. If you remove the app in Slack, delivery falls back to email and the audit trail records it.

SDKs: Python and TypeScript

Both are thin, zero-dependency clients with one rule: guard returns only when a named person approved. Rejection or expiry raises Denied; silence raises TimedOut. Source and contract tests: github.com/agentlabbusiness/raposa-sdk (MIT).

# Python — pip install raposa
from raposa import Raposa, Denied, TimedOut
gate = Raposa()                                        # RAPOSA_API_KEY from the environment
try:
    d = gate.guard(action="refund", context="order 42, EUR 120", risk="high", requested_by="support-agent")
except (Denied, TimedOut) as exc:
    return f"not issued: {exc}"
do_the_refund()                                        # only here
// TypeScript — npm i raposa-sdk
import { Raposa, Denied, TimedOut } from "raposa-sdk";
const gate = new Raposa();
try { await gate.guard({ action: "refund", context: "order 42", risk: "high", requestedBy: "support-agent" }); }
catch (e) { if (e instanceof Denied || e instanceof TimedOut) return "not issued"; throw e; }

Request options pass through both: remind_after_sec, escalate_to, approvers, required, webhook_url. create returns the id without waiting; get, wait, me, export_audit mirror the API.

Using it from Claude Code, Cursor or any MCP client

The MCP server raposa-mcp gives the agent one tool, request_human_approval(action, context, risk), which returns approved: true only when a named person pressed Approve. A timeout, an expiry or a rejection come back as approved: false — silence is not consent. Two more tools, create_approval (returns the id at once, for webhook flows) and get_approval.

claude mcp add raposa -e RAPOSA_API_KEY=<your key> -- uvx raposa-mcp
{"mcpServers": {"raposa": {"command": "uvx", "args": ["raposa-mcp"],
                          "env": {"RAPOSA_API_KEY": "<your key>"}}}}

The key is read from the environment only and never appears in tool inputs or outputs. Source and contract tests: github.com/agentlabbusiness/raposa-mcp (MIT).

Remote MCP over HTTP (Claude.ai, ChatGPT, Smithery, any Streamable HTTP client)

The same three tools are served at https://dcescrypt.com/api/mcp — Streamable HTTP, stateless, plain JSON answers. Authenticate with your API key as a Bearer token; nothing to install.

{"mcpServers": {"raposa": {"url": "https://dcescrypt.com/api/mcp",
                          "headers": {"Authorization": "Bearer <your key>"}}}}
curl -X POST https://dcescrypt.com/api/mcp \
  -H "Authorization: Bearer <your key>" -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

request_human_approval waits up to 50 seconds per call over HTTP; if nobody has decided by then it returns status: "timeout", approved: false and the id — the request stays open, read it later with get_approval. Tool schemas (tools/list) are public so directories can scan the server; every tools/call needs the key, and a key sees only its own approvals. Gateways that can only forward a plain header may send the key as X-Raposa-Key.

Using it from n8n

No code at all: install the community node n8n-nodes-raposa (Settings → Community Nodes → Install) and add a Raposa Approval node in front of any step that moves money or changes something irreversible. Credentials: your API key; the credential test calls GET /v1/me.

Three behaviours worth copying

CaseWhat your code should do
Human approvesproceed, and keep the approval id in your own logs
Human rejects, or the request expiresdo not proceed, and tell the user why
Nobody answers before your timeoutdo not proceed — silence is not consent