A customer paid and is still on the free plan. By the end of this page a request like that arrives on its own, your playbook proposes the exact fix, a person approves it in one tap, your system applies it, and you hold a receipt naming who authorised what. Around twenty minutes.
conxt holds no standing access to your systems. It cannot reach into your database or your billing provider. To change anything it sends a signed, scoped request to one endpoint you registered, and your code decides what that means.
The worked example here is restoring a paid customer’s entitlement. Credits, downgrades and account closures are the same five steps with a different action name.
Nothing in this quickstart can change anything until a person presses Approve. Until then the whole loop is safe to run against production.
One HTTPS endpoint on a public address. It verifies the signature, applies the change, and answers { "ok": true, "result": { ... } }. Roughly thirty lines.
// The one endpoint conxt calls. It verifies the signature, then acts.
import express from 'express';
import crypto from 'node:crypto';
const SECRET = process.env.CONXT_SIGNING_SECRET; // the secret you registered
const TEAM = process.env.CONXT_TEAM_ID; // the only team this serves
const app = express();
// The signature covers the RAW body, so keep it raw. A JSON parser that
// re-serialises first will change the bytes and every signature will fail.
app.post('/conxt/actions', express.raw({ type: '*/*' }), async (req, res) => {
const raw = req.body.toString('utf8');
const signature = req.get('X-Conxt-Signature') || '';
const timestamp = req.get('X-Conxt-Timestamp') || '';
const expected = crypto.createHmac('sha256', SECRET)
.update(timestamp + '.' + raw).digest('hex');
const signed = signature.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
if (!signed) return res.status(401).json({ ok: false, error: 'bad signature' });
const sentAt = Number(timestamp); // NaN is not > 300, so check it is finite
if (!Number.isFinite(sentAt) || Math.abs(Date.now() / 1000 - sentAt) > 300) {
return res.status(401).json({ ok: false, error: 'stale timestamp' });
}
const body = JSON.parse(raw);
const { action, subject, params, idempotency_key } = body;
// The test ping. It must answer and change nothing.
if (action === 'conxt.ping') return res.json({ ok: true, result: { pong: true } });
// A signature proves WHO sent this, never what they may touch. Check the
// team yourself. The ping is answered above, so a passing test never
// reaches this line: a connection can look healthy with the check missing.
if (body.team_id !== TEAM) return res.status(403).json({ ok: false, error: 'wrong team' });
if (action !== 'grant_entitlement') {
return res.json({ ok: false, error: 'action not supported here' });
}
// Your code. Record idempotency_key alongside the change, in one
// transaction, so a retry is recognised as one.
const applied = await grantEntitlement(subject.email, params.entitlement, idempotency_key);
res.json({ ok: true, result: { entitlement: applied.tier, effective_at: applied.at } });
});What conxt sends looks like this. The body is compact JSON with sorted keys, and the signature is HMAC-SHA256 over timestamp + "." + body.
{
"action": "grant_entitlement",
"idempotency_key": "08ac6073-...", // the approval that authorised this
"subject": { "email": "pat.nolan@example.com" },
"params": { "entitlement": "pro" }, // only what the playbook declared
"requested_by": "conxt-agent",
"team_id": "8c781acd-...",
"ticket_id": "c69b84cd-...",
"issued_at": 1758772240
}The idempotency key is the approval id. Store it with the change, in the same transaction, and a retry is recognised as the same approval rather than applied a second time.
Check team_id yourself. A valid signature says the request came from conxt, not which team it may act for, and the ping is answered before any of this runs: a connection can test green while that check is missing.
In Settings, Connect your system: a name, your endpoint, a signing secret of at least 32 characters, and the actions conxt may ask for. Only a team owner can do this, because a connection decides where every approved action is sent.
conxt.ping, signed exactly like a real one.A passing ping means a real action would reach you and be accepted: same public address check, same pinned connection, same refusal to follow redirects. Your endpoint must answer the ping without doing anything.
A playbook says when it applies, what it may use, and what that action is allowed to take. Declared fields are the only fields that ever leave conxt: an extra field in an incoming request never reaches your system.
You need an approved playbook that matches requests like this one and permits the tool before any of this proposes anything. Most playbooks are approved declaring no tools at all, which permits everything and authorises nothing, and the parameter editor only offers actions a playbook already allows. Declaring the one tool you want to run is usually the whole setup.
"action_params": {
"system.action:grant_entitlement": {
"fields": {
"entitlement": { "type": "string", "required": true, "enum": ["pro", "plus"] }
}
}
}Limits are part of the declaration, not a convention: max and min on numbers, max_length on strings, max_items on arrays, enum for a fixed set. A refund playbook caps the amount here, and nothing can propose above the cap.
Values are checked when the action is proposed and again when it is approved, against the playbook as it stands at that moment. Tightening a limit takes effect on approvals that are already waiting.
Mint an inbound key in Settings, Send work into conxt. It is shown once, it can submit requests for this team and nothing else. Then send the request to /ingest/request/test first, which stores nothing and tells you what would happen.
curl -X POST https://YOUR-CONXT-ENGINE/ingest/request/test \
-H "Authorization: Bearer cnxt_in_YOUR_KEY" \
-H "Content-Type: application/json" \
-d '{
"kind": "billing.entitlement_missing",
"external_id": "zd-48213",
"title": "Paid but still on the free plan",
"requester": { "name": "Pat Nolan", "email": "pat.nolan@example.com" },
"identity_verified": true,
"payload": { "entitlement": "pro", "invoice_id": "in_1S2f4K" }
}'{
"stored": false,
"decision": "execute_workflows",
"matched": { "playbook_id": "billing.restore-entitlement.v1" },
"would_propose": true,
"tools": ["system.action:grant_entitlement"],
"guardrails": { "known": true, "blocking": [], "advisory": [] },
"identity": { "verified_by_sender": true }
}would_propose is the line that matters. If it is false, the request matched no playbook that permits an action, which is nearly always the playbook declaring no tools rather than the request being wrong. Going live in that state classifies the request and waits for a human rather than proposing anything. When the dry run says what you expect, send the same body to /ingest/request.
external_id is the idempotency key for intake: the same one twice returns the original request instead of raising a second one. identity_verified is your system vouching that it knows who the requester is, and it is recorded as your assertion, not as fact.
The proposal appears in Approvals, showing the action, the subject, the exact parameter values, and the playbook that permits it. One tap approves those values. Declining records the decision and nothing is sent.
An approval is a signed, short-lived claim bound to one proposal. It cannot be replayed against a different one, and it expires if nobody acts on it.
If an approval cannot complete, for any reason, it says so and the reason is on the audit trail. Silence is never the answer.
Every action leaves a receipt. It is the part of this product that outlives the agent: months later it is the answer to who changed this, on whose authority, and what was true at the time.
did_not_match when an observation missed what was expected, whatever your system reported.A receipt is careful about the difference between your system said it worked and we checked afterwards and it had. That is why reported_outcome and outcome are two fields and not one. The exact values that were approved sit on the proposal and its audit entry, which the receipt points at.
Worth knowing before you point this at production, because these are structural, not settings someone can talk their way past.
The same six steps carry the rest of the queue. Change the action name, declare its parameters, and tick it on the connection.
Refunds can also run without an endpoint of your own, through conxt’s built-in Stripe path, which re-reads the charge afterwards to confirm the refund landed. That path is set up with us directly today rather than self-serve.
Building for access requests rather than billing? The loop is identical, with the action naming the app and role. Tell us what you need to reach and we will work through it with you.