Product

Product overview Agents Automations Apps Code (Beta) Integrations Governed Runtime Security

Where Renly runs

Windows app Android app Inside other AI apps

Solutions

Finance Sales Operations People and HR Marketing Enterprise

Resources

Pricing Docs Academy Blog Compare Support

AI governance and capability

AI governance framework AI governance assessment AI capability maturity AI maturity assessment
Book a demo Sign in
Docs / Renly Apps / API and webhooks

API and webhooks Pro Plus and Enterprise

Read and write an app's records from your own systems with an API key, and be told when they change with signed webhooks. A key is a service actor: the app's write rules apply to it, and a held write comes back as 202 with a request to approve.

API keys

Organisation owners and admins create keys under Organisation settings > Governance > Apps > API keys.

  • A key starts with rk_app_. Only a hash is stored; the secret is shown once, on create and on rotate.
  • Scopes per app: read and/or write, optionally limited to named record types, and optional field grants by name (how a key is given a sensitive or hidden field). Every app, type and field must exist in your organisation.
  • Expiry of 1 to 365 days, or none. A rate limit of 10 to 1,200 requests a minute (default 120). At most 100 active keys per organisation.
  • Rotate keeps the key's id and can keep the old secret working for 0 to 1,440 minutes. Revoke ends both at once.
  • The key belongs to the organisation, not to its creator: it keeps working if the creator leaves, and an admin revokes it. Its creator is the accountable person for the writes it makes: they are notified of decisions on its held requests and may withdraw them, but can never approve them.

Authentication and errors

Send the key as Authorization: Bearer rk_app_... or X-API-Key: rk_app_.... Session tokens are refused on this API. Every call checks the key (401 with APP_KEY_UNKNOWN, APP_KEY_REVOKED, APP_KEY_EXPIRED, APP_KEY_INVALID or APP_KEY_REQUIRED) and then the organisation (403 with ORGANIZATION_SUSPENDED, SUBSCRIPTION_CANCELLED, TRIAL_EXPIRED or RECORDS_NOT_ENABLED). Over the key's rate: 429 RATE_LIMITED with RateLimit-* headers. After 30 failed attempts in a minute from one address, further failures from that address get 429 TOO_MANY_FAILED_ATTEMPTS with Retry-After for the minute; a valid key from the same address keeps working.

What a key sees

A key (and a webhook) sees the app's service view: never a sensitive field, and never a field the app's default Viewer role hides or masks; everything else is visible, and writable with write access. A field grant opens more, by name. Hidden fields are omitted, never masked. A filter or sort on a field the key cannot read answers exactly as an unknown field (400 UNKNOWN_FIELD). Row scope is all rows. An app or type outside the key's scope is 404; a type in scope without the needed access is 403 APP_KEY_SCOPE_DENIED.

Endpoints

Base URL: https://renly.ai/api/v1/apps. Responses use { success, data?, error? }.

RequestWhat it does
GET /The apps and types the key may use, with links.
GET /:slug/openapi.jsonAn OpenAPI 3.1 document for this key: its types, the fields of its service view (types, choice values with labels, lookup targets, required flags), read or write operations by access, the 202, 409, 422 and 423 responses and the bearer scheme.
GET /:slug/types/:type/recordsList: limit 1 to 100, cursor, sort=-amount,name (3 keys at most), match=and|or, filter[field]=value or filter[field][op]=value; in takes a comma list; is_empty and is_not_empty take no value.
POST /:slug/types/:type/records/queryThe same list with the Records JSON grammar in the body.
POST /:slug/types/:type/recordsCreate: { fields, ownerUserId? }. 201. The owner must be an active member who can read the type; otherwise the record has no owner.
GET /:slug/types/:type/records/:idOne record.
PATCH /:slug/types/:type/records/:idUpdate: { expectedVersion, fields }.
DELETE /:slug/types/:type/records/:id?expectedVersion=nDelete (soft).

A record is { id, type, version, primaryValue, fields, ownerUserId, createdAt, updatedAt }. A write-only key gets { id, type, version, createdAt, updatedAt } back, never data.

Idempotency and versions

  • Writes need an Idempotency-Key header of 16 to 128 characters, namespaced to the key. A retry with the same key returns the first result; the same key with a different body is 409 IDEMPOTENCY_KEY_CONFLICT.
  • Updates and deletes send the record's expectedVersion. A stale version is 409.

Held, refused and paused writes

StatusMeaning
202A write rule held the change: { success: true, data: { status: 'pending_approval', requestId, message, require } }. The write is not applied until an approver decides. Subscribe to write_request.decided, or poll your own system, to learn the outcome.
422 RECORD_WRITE_DENIEDA rule refuses this change.
423 RECORD_APP_PAUSEDAn administrator paused the app. Reads continue.

An approved request runs with the key's own access, only while the key is live and may still write that type. Records the key creates are marked as created by the API key, and the Records event names the key.

Examples

curl https://renly.ai/api/v1/apps/request-tracker/types/request/records \
  -H "Authorization: Bearer rk_app_..." \
  --get --data-urlencode "filter[status][in]=new,triaged" \
  --data-urlencode "sort=-createdAt" --data-urlencode "limit=50"
curl -X POST https://renly.ai/api/v1/apps/request-tracker/types/request/records \
  -H "Authorization: Bearer rk_app_..." \
  -H "Idempotency-Key: 2f6c1d8e-7a1b-4c0e-9b2a-5d3e8f1a7c44" \
  -H "Content-Type: application/json" \
  -d '{ "fields": { "title": "Replace the lobby printer", "priority": "high" } }'
curl -X PATCH https://renly.ai/api/v1/apps/request-tracker/types/request/records/rec_01HZ... \
  -H "Authorization: Bearer rk_app_..." \
  -H "Idempotency-Key: 9c0a4b2d-1e7f-4a3c-8d5b-6f2e1a9c3b70" \
  -H "Content-Type: application/json" \
  -d '{ "expectedVersion": 4, "fields": { "status": "approved" } }'
# 200 applied, or 202 { "status": "pending_approval", "requestId": "..." } when a rule holds it

Webhooks

Organisation owners and admins add webhooks under Organisation settings > Governance > Apps > Webhooks, per app, up to 25.

  • An https URL on a public address, with no credentials in it, checked when saved and again on every delivery.
  • Events: record.created, record.updated, record.deleted, write_request.created, write_request.decided. A types filter limits the webhook to named record types; empty means all. A live source's events are matched as source:<sourceId>:<entity>.
  • A whsec_ secret, shown once. Rotation can keep the old secret signing alongside the new one for 0 to 1,440 minutes.
  • Test sends a webhook.test event. Deliveries show 30 days of history, with redelivery.

Payload

{
  "id": "evt_...",
  "type": "record.updated",
  "occurredAt": "2026-10-15T03:12:44.000Z",
  "app": { "id": "...", "slug": "request-tracker", "name": "Request tracker" },
  "recordType": { "id": "...", "slug": "request", "name": "Request" },
  "data": {
    "record": { "id": "rec_...", "primaryValue": "Replace the lobby printer", "version": 5, "fields": { ... } },
    "changes": [{ "field": "status", "from": "triaged", "to": "approved" }]
  }
}

Record events carry the record as it was at the change, filtered through the service view with no grants. Write-request events carry data.request: id, status, action, record id, what the rule required, the rule, the message, how it was requested, expiry, decision time, reason and, for imports, the row count. A held write to a live source has recordType: null, a source object and the changed field names, never their values.

Delivery

  • A POST with Content-Type: application/json, User-Agent: Renly-Webhooks/1.0, X-Renly-Event, X-Renly-Delivery (the delivery id) and X-Renly-Signature. A 10 second timeout; redirects are never followed. Any 2xx is success.
  • Retries after 1, 3, 9, 27, 81, 243 and then 360 minutes (give or take 20 percent): 8 attempts in all, then the delivery is dead (kept, and redeliverable). Deliveries are at least once and unordered, so dedupe on X-Renly-Delivery or the body's id.
  • Auto-disable: 20 consecutive failed attempts over at least an hour turn the webhook off, dead-letter its queued deliveries and notify its creator (or the owners and admins if the creator has left). Turning it on again resets the count.
  • Record events come from the Records outbox and cannot be lost. Write-request events are sent best effort.

Verifying the signature

X-Renly-Signature is t=<unix seconds>,v1=<hex>, where v1 is HMAC-SHA256 over <t>.<raw body> with your whsec_... secret as the key. During a rotation there may be two v1 parts. Use the raw request body exactly as received, and reject timestamps more than five minutes old.

// Node.js (Express: app.post('/renly', express.raw({ type: 'application/json' }), handler))
const crypto = require('crypto');
function verifyRenlySignature(rawBody, header, secret, toleranceSeconds = 300) {
  let timestamp = null;
  const signatures = [];
  for (const part of header.split(',')) {
    const [key, value] = part.split('=');
    if (key === 't') timestamp = Number(value);
    if (key === 'v1') signatures.push(value);
  }
  if (!timestamp || Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) return false;
  const expected = crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest();
  return signatures.some((sig) => {
    const given = Buffer.from(sig, 'hex');
    return given.length === expected.length && crypto.timingSafeEqual(given, expected);
  });
}
// verifyRenlySignature(req.body.toString('utf8'), req.get('X-Renly-Signature'), process.env.RENLY_WEBHOOK_SECRET)
import hashlib, hmac, time

def verify_renly_signature(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = [p.split("=", 1) for p in header.split(",") if "=" in p]
    timestamp = next((int(v) for k, v in parts if k == "t" and v.isdigit()), None)
    signatures = [v for k, v in parts if k == "v1"]
    if timestamp is None or abs(time.time() - timestamp) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256).hexdigest()
    return any(hmac.compare_digest(expected, s) for s in signatures)

Answer 2xx quickly (within 10 seconds) and do the work afterwards. Ignore a delivery id you have already processed.

Keys for agents are different

Apps API keys read and write records as a service. Agent access keys let outside tools use Renly agents over A2A and MCP as you. They are separate keys with separate scopes.