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.
On this path conxtagents 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, and a refund is written out in full below.
Nothing in this quickstart can change anything until a person presses Approve. Until then the whole loop is safe to run against production. Later, an owner can let one specific action run on its own once it has a clean record; nothing starts that way.
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 conxtagents 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 conxtagents 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 conxtagents, 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 conxtagents 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 conxtagents: 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. A playbook can’t be approved until it declares the tools it may use, and the parameter editor only offers actions the playbook 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 conxtagents. 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 not declaring this action 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 most common case, and the same six steps. A duplicate charge arrives, the playbook proposes a refund of one specific charge for a capped amount, a person approves those values, and your endpoint calls Stripe with your own key. conxtagents never holds it.
Declare what the action may take. The cap is the point: nothing can propose above it, and it is checked again at the moment of approval, against the playbook as it stands then.
"action_params": {
"system.action:refund_charge": {
"fields": {
"charge_id": { "type": "string", "required": true, "max_length": 64 },
"amount_cents": { "type": "integer", "required": true, "max": 50000 },
"reason": { "type": "string", "required": false,
"enum": ["duplicate", "requested_by_customer"] }
}
}
}Then one branch in the endpoint you already built.
// Inside the same endpoint, next to your other actions.
if (action === 'refund_charge') {
// conxtagents's idempotency key IS the approval id. Handing it to Stripe as
// Stripe's own idempotency key makes the whole path exactly-once: if this
// call is retried, for any reason, Stripe returns the first refund rather
// than issuing a second.
const refund = await stripe.refunds.create(
{ charge: params.charge_id, amount: params.amount_cents, reason: params.reason },
{ idempotencyKey: idempotency_key },
);
return res.json({
ok: true,
result: { refund_id: refund.id, amount_refunded: refund.amount, status: refund.status },
});
}That second argument is worth the detour. The idempotency key conxtagents sends is the approval id, so passing it to Stripe as Stripe’s idempotency key makes the path exactly-once from the approval all the way to the money: a retry cannot produce a second refund, even if your own bookkeeping fails between the two.
Be clear about what the receipt can claim here. conxtagents has your endpoint’s word that the refund happened, and the refund id it returned. It does not hold your Stripe key, so it cannot go and look. The receipt says exactly that, which is why it carries what your system reported separately from what conxtagents will stand behind.
Refunds can also run without an endpoint of your own, through conxtagents’ built-in Stripe path, which does re-read the charge afterwards to confirm the refund landed. That path runs on conxtagents’ credentials rather than yours, so it is set up with us directly rather than self-serve.
The same six steps carry the rest of the queue. Change the action name, declare its parameters, and tick it on the connection.
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.