[{"data":1,"prerenderedAt":679},["ShallowReactive",2],{"search-en":3,"content-en-developer\u002Fplugin-connected-apps":4,"surround-en-\u002Fdeveloper\u002Fplugin-connected-apps":670},[],{"id":5,"title":6,"body":7,"description":662,"extension":663,"meta":664,"navigation":665,"path":666,"seo":667,"stem":668,"__hash__":669},"content_en\u002F3.developer\u002F46.plugin-connected-apps.md","Connected Apps & Signed Hooks",{"type":8,"value":9,"toc":648},"minimark",[10,19,24,125,128,132,143,158,164,168,183,193,236,240,243,325,330,336,341,348,435,438,444,451,454,458,539,546,550,553,616,620,623,635,638,642],[11,12,13,14,18],"p",{},"A ",[15,16,17],"strong",{},"connected app"," is an external HTTPS service bound to a plugin id. It runs out of process and never executes code inside Convex or Nuxt, so it installs instantly — no rebuild, no redeploy. In exchange, everything it says is treated as untrusted.",[20,21,23],"h2",{"id":22},"the-record","The record",[25,26,27,40],"table",{},[28,29,30],"thead",{},[31,32,33,37],"tr",{},[34,35,36],"th",{},"Field",[34,38,39],{},"Notes",[41,42,43,55,65,83,93,117],"tbody",{},[31,44,45,52],{},[46,47,48],"td",{},[49,50,51],"code",{},"pluginId",[46,53,54],{},"The bundled plugin this app is bound to. Its manifest is the capability ceiling",[31,56,57,62],{},[46,58,59],{},[49,60,61],{},"name",[46,63,64],{},"Display name",[31,66,67,72],{},[46,68,69],{},[49,70,71],{},"endpointUrl",[46,73,74,75,78,79,82],{},"Must be absolute ",[15,76,77],{},"https",", with a hostname and no embedded ",[49,80,81],{},"user:pass@"," credentials",[31,84,85,90],{},[46,86,87],{},[49,88,89],{},"grantedCapabilities",[46,91,92],{},"A de-duplicated subset of the bound plugin's declared capabilities. Requesting anything the manifest does not declare is rejected at registration",[31,94,95,100],{},[46,96,97],{},[49,98,99],{},"status",[46,101,102,105,106,109,110,112,113,116],{},[49,103,104],{},"enabled"," → ",[49,107,108],{},"disabled"," ⇄ ",[49,111,104],{},", or ",[49,114,115],{},"revoked"," (terminal)",[31,118,119,122],{},[46,120,121],{},"Shared secret",[46,123,124],{},"AES-256-GCM sealed envelope; never stored, logged, or returned in plaintext after minting",[11,126,127],{},"Read paths return one projection that omits the sealed secret columns by construction, so a new query cannot accidentally surface the ciphertext.",[20,129,131],{"id":130},"registration-and-secret-exchange","Registration and secret exchange",[11,133,134,135,138,139,142],{},"Registration mints a 256-bit secret with the recognizable ",[49,136,137],{},"cah_"," prefix and seals it under a key derived by HKDF-SHA256 from ",[49,140,141],{},"INSTANCE_SECRET"," with connected-app-specific salt and info labels — a connected-app secret can never be opened under another consumer's key context.",[144,145,148],"callout",{"title":146,"type":147},"The plaintext secret is shown exactly once","warning",[11,149,150,153,154,157],{},[49,151,152],{},"register"," and ",[49,155,156],{},"rotateSecret"," return the plaintext to the caller and nowhere else. Store it in your app immediately; if it is lost, rotate.",[11,159,160,163],{},[49,161,162],{},"testConnection"," probes the configured endpoint (an unsigned reachability check) so an operator can validate setup before granting anything. Disable, revoke, and delete are separate operations; revoke is terminal.",[20,165,167],{"id":166},"api-scopes-and-plugin-bound-keys","API scopes and plugin-bound keys",[11,169,170,171,174,175,178,179,182],{},"A connected app authenticates to the v1 HTTP API with an API key ",[15,172,173],{},"bound"," to its plugin (",[49,176,177],{},"apiKeys.pluginId","). The scope string doubles as the plugin-capability string, and the effective scope set is re-derived on ",[15,180,181],{},"every"," request:",[184,185,191],"pre",{"className":186,"code":188,"language":189,"meta":190},[187],"language-text","effective scopes = key scopes ∩ plugin manifest capabilities ∩ operator grants ∩ (plugin flag enabled)\n","text","",[49,192,188],{"__ignoreMap":190},[11,194,195,196,199,200,199,203,199,206,199,209,212,213,199,216,199,219,199,222,199,225,199,228,231,232,235],{},"Disabling the plugin or revoking a grant therefore neutralizes the key immediately. Endpoint scopes (",[49,197,198],{},"contacts:read",", ",[49,201,202],{},"contacts:write",[49,204,205],{},"events:write",[49,207,208],{},"transactional:send",[49,210,211],{},"topics:write",") are also valid on standalone operator keys. The Tier-2-only scopes (",[49,214,215],{},"campaigns:read",[49,217,218],{},"mail:read",[49,220,221],{},"knowledge:read",[49,223,224],{},"webhooks:manage",[49,226,227],{},"plugin-storage:read",[49,229,230],{},"plugin-storage:write",") have no standalone meaning and are rejected at mint on an unbound key. Keys are least-privilege by construction: creation requires an explicit, non-empty scope list, and a legacy row with no ",[49,233,234],{},"scopes"," column is deny-all.",[20,237,239],{"id":238},"signed-synchronous-hooks","Signed synchronous hooks",[11,241,242],{},"The hook protocol defines three pipeline decision points a connected app can answer:",[25,244,245,258],{},[28,246,247],{},[31,248,249,252,255],{},[34,250,251],{},"Hook",[34,253,254],{},"What the app returns",[34,256,257],{},"Fail direction",[41,259,260,283,299],{},[31,261,262,267,273],{},[46,263,264],{},[49,265,266],{},"draft",[46,268,269,272],{},[49,270,271],{},"{ draft: string }"," — a proposed reply body",[46,274,275,278,279,282],{},[15,276,277],{},"Open",": falls back to the built-in ",[49,280,281],{},"default"," draft strategy",[31,284,285,290,293],{},[46,286,287],{},[49,288,289],{},"gate",[46,291,292],{},"A restrict-only verdict",[46,294,295,298],{},[15,296,297],{},"Closed",": falls back to a caution objection (human review)",[31,300,301,306,320],{},[46,302,303],{},[49,304,305],{},"score",[46,307,308,311,312,316,317],{},[49,309,310],{},"{ score: number }"," in ",[313,314,315],"span",{},"0, 1",", optional ",[49,318,319],{},"reason",[46,321,322,324],{},[15,323,277],{},": falls back to \"no score\"",[11,326,13,327,329],{},[49,328,289],{}," response is structurally incapable of approving: there is no accept value in the schema. A connected app can add caution or work; it can never approve, unblock, or force a send.",[144,331,333],{"title":332,"type":147},"Protocol only: runtime adapter not shipped",[11,334,335],{},"The signed hook wire protocol is specified, but Owlat does not ship a Convex runtime adapter or pipeline call site for it. The draft, route-gate and scoring stages run core and Tier-1 contributions only, so registering a connected app does not cause Owlat to call it at a decision point. The live half of Tier 2 is plugin-bound API keys (scope intersection re-derived per request) and connected-app registration with its sealed shared secret. Everything below \"Wire contract\" describes the contract a future implementation must satisfy; do not expect inbound hook traffic today.",[337,338,340],"h3",{"id":339},"wire-contract","Wire contract",[11,342,343,344,347],{},"Protocol version ",[49,345,346],{},"v1",". Request headers:",[25,349,350,360],{},[28,351,352],{},[31,353,354,357],{},[34,355,356],{},"Header",[34,358,359],{},"Value",[41,361,362,378,389,399,413,423],{},[31,363,364,369],{},[46,365,366],{},[49,367,368],{},"x-owlat-hook",[46,370,371,373,374,373,376],{},[49,372,266],{}," | ",[49,375,289],{},[49,377,305],{},[31,379,380,385],{},[46,381,382],{},[49,383,384],{},"x-owlat-hook-version",[46,386,387],{},[49,388,346],{},[31,390,391,396],{},[46,392,393],{},[49,394,395],{},"x-owlat-hook-app",[46,397,398],{},"Connected-app id, so the receiver selects the right secret",[31,400,401,406],{},[46,402,403],{},[49,404,405],{},"x-owlat-hook-timestamp",[46,407,408,409,412],{},"Unix ",[15,410,411],{},"seconds",", signed",[31,414,415,420],{},[46,416,417],{},[49,418,419],{},"x-owlat-hook-nonce",[46,421,422],{},"Per-request 128-bit base64url nonce, signed",[31,424,425,430],{},[46,426,427],{},[49,428,429],{},"x-owlat-hook-signature",[46,431,432],{},[49,433,434],{},"v1=\u003Chex hmac>",[11,436,437],{},"Both directions are HMAC-SHA256 over a newline-joined canonical string with a fixed field order and a direction-specific domain tag:",[184,439,442],{"className":440,"code":441,"language":189,"meta":190},[187],"request:                          response:\n  owlat.hook.request.v1             owlat.hook.response.v1\n  \u003ChookKind>                        \u003ChookKind>\n  \u003CconnectedAppId>                  \u003CconnectedAppId>\n  \u003CtimestampSeconds>                \u003Cnonce>            ← echoes the REQUEST nonce\n  \u003Cnonce>                           \u003CtimestampSeconds>\n  \u003Csha256Hex(bodyBytes)>            \u003Csha256Hex(bodyBytes)>\n",[49,443,441],{"__ignoreMap":190},[11,445,446,447,450],{},"The body is bound by its SHA-256, so tampering invalidates the signature. The direction tag means a request signature can never be replayed as a response signature. The ",[15,448,449],{},"request nonce is folded into the response signing string",", so a captured response cannot be replayed against a different request. Verification is constant-time.",[11,452,453],{},"Your app must verify the request signature and enforce its own freshness window on the signed timestamp, then sign its response the same way, echoing the request nonce. Owlat rejects a response whose signed timestamp is outside a 30-second tolerance.",[337,455,457],{"id":456},"what-owlat-enforces-on-every-call","What Owlat enforces on every call",[25,459,460,469],{},[28,461,462],{},[31,463,464,467],{},[34,465,466],{},"Control",[34,468,359],{},[41,470,471,479,487,495,503,515,523,531],{},[31,472,473,476],{},[46,474,475],{},"Deadline",[46,477,478],{},"5 s, then the fetch is aborted",[31,480,481,484],{},[46,482,483],{},"Request body cap",[46,485,486],{},"64 KiB",[31,488,489,492],{},[46,490,491],{},"Response body cap",[46,493,494],{},"64 KiB (drained under the cap; over-cap fails closed)",[31,496,497,500],{},[46,498,499],{},"Response freshness",[46,501,502],{},"30 s tolerance",[31,504,505,508],{},[46,506,507],{},"Transport",[46,509,510,511,514],{},"https only, SSRF guard with a private\u002Finternal blocklist applied up front ",[15,512,513],{},"and"," at connect time; redirects refused",[31,516,517,520],{},[46,518,519],{},"Accepted draft text",[46,521,522],{},"Injection-scrubbed and clamped to 65 536 code points",[31,524,525,528],{},[46,526,527],{},"Accepted reason text",[46,529,530],{},"Injection-scrubbed and clamped to 300 code points",[31,532,533,536],{},[46,534,535],{},"Circuit breaker",[46,537,538],{},"5 consecutive failures per (app, kind) opens it; a 60 s cooldown then allows one half-open trial",[11,540,541,542,545],{},"The runtime resolves the app and circuit state first and ",[15,543,544],{},"short-circuits without opening the secret or making a network call"," for a missing, disabled, or revoked app, a hook kind the operator has not granted, or an open breaker. Responses are strictly shape-validated: an extra key, a wrong type, or an empty string is rejected and the declared fallback applies.",[337,547,549],{"id":548},"failure-taxonomy","Failure taxonomy",[11,551,552],{},"Every resolution records one fixed code — never free text and never the app's own message:",[11,554,555,199,558,199,561,199,564,199,567,199,570,199,573,199,576,199,579,199,582,199,585,199,588,199,591,199,594,199,597,199,600,199,603,199,606,199,609,199,612,615],{},[49,556,557],{},"request_too_large",[49,559,560],{},"blocked_ssrf",[49,562,563],{},"redirect_refused",[49,565,566],{},"timeout",[49,568,569],{},"network_error",[49,571,572],{},"bad_status",[49,574,575],{},"response_too_large",[49,577,578],{},"signature_missing",[49,580,581],{},"signature_mismatch",[49,583,584],{},"stale_response",[49,586,587],{},"invalid_json",[49,589,590],{},"invalid_response",[49,592,593],{},"app_not_found",[49,595,596],{},"app_disabled",[49,598,599],{},"app_revoked",[49,601,602],{},"capability_denied",[49,604,605],{},"circuit_open",[49,607,608],{},"secret_unavailable",[49,610,611],{},"output_rejected",[49,613,614],{},"unexpected_error",".",[20,617,619],{"id":618},"delivery-logs","Delivery logs",[11,621,622],{},"Each resolution writes a redacted, tenant-scoped delivery-log row alongside the outbound webhook logs: hook kind, whether a network call was attempted, whether the app's value or the fallback won, the fixed fallback reason, and the network duration.",[11,624,625,626,629,630,634],{},"The redaction is ",[15,627,628],{},"structural",": there is no column for the payload, the returned draft\u002Fgate\u002Fscore text, the shared secret, or either signature. A logged delivery therefore cannot be replayed ",[631,632,633],"em",{},"from"," the log. The only replay is the pipeline re-invoking the hook, which signs a fresh timestamp and nonce and re-runs the full restrict-only envelope — so a replayed gate can still only add caution.",[11,636,637],{},"Reads are org-scoped and bounded (default 50 rows, maximum 200, filterable by app, kind, and source). Rows age out at the audit-log retention of 30 days.",[20,639,641],{"id":640},"reference-implementation","Reference implementation",[11,643,644,647],{},[49,645,646],{},"examples\u002Fplugins\u002Fslack-approvals"," is the maintained Tier-2 reference: a restrict-only hold gate plus automation notifications, with authenticated Slack callbacks, expiration, duplicate-vote and quorum modelling — and tests proving Slack cannot force an approval or bypass Owlat's core gates.",{"title":190,"searchDepth":649,"depth":649,"links":650},2,[651,652,653,654,660,661],{"id":22,"depth":649,"text":23},{"id":130,"depth":649,"text":131},{"id":166,"depth":649,"text":167},{"id":238,"depth":649,"text":239,"children":655},[656,658,659],{"id":339,"depth":657,"text":340},3,{"id":456,"depth":657,"text":457},{"id":548,"depth":657,"text":549},{"id":618,"depth":649,"text":619},{"id":640,"depth":649,"text":641},"Tier 2: external services reached with plugin-bound API keys, scoped webhooks, and signed synchronous draft\u002Fgate\u002Fscore hooks.","md",{},true,"\u002Fdeveloper\u002Fplugin-connected-apps",{"title":6,"description":662},"3.developer\u002F46.plugin-connected-apps","Chpf0pxIpKDsPa9mPrNZriRA8j0gOi1OV05BP9q-kqA",[671,675],{"title":672,"path":673,"stem":674,"children":-1},"The owlat plugins CLI","\u002Fdeveloper\u002Fplugin-cli","3.developer\u002F45.plugin-cli",{"title":676,"path":677,"stem":678,"children":-1},"Sandboxed Plugin Jobs","\u002Fdeveloper\u002Fplugin-sandboxed-jobs","3.developer\u002F47.plugin-sandboxed-jobs",1786915092780]