Docs · Quickstart

One money fix, end to end.

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.

00 · Concept

The loop

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.

A request arrives from your helpdesk, your app or a script.
Your playbook matches it and proposes one action with specific values.
A person on your team approves those exact values, or declines.
Only then does conxt call your endpoint, once, with an idempotency key.
The receipt records the playbook that authorised it and every check that passed.

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.

01 · Step one

Your endpoint

One HTTPS endpoint on a public address. It verifies the signature, applies the change, and answers { "ok": true, "result": { ... } }. Roughly thirty lines.

receiver.js
// 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.

POST https://your-app.com/conxt/actions
{
  "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.

02 · Step two

Register it, then test it

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.

Generate the signing secret and give the same value to your endpoint.
Tick only the actions this endpoint should ever be asked for.
Press Test. conxt sends the reserved action 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.

03 · Step three

Declare the action

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.

playbook · action_params
"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.

04 · Step four

Send a request

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.

a dry run
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" }
  }'
the answer, abridged
{
  "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.

05 · Step five

Approve it

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.

06 · Step six

The receipt

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.

receipt_id
The approval this receipt belongs to, which is also the idempotency key your endpoint was sent.
action.playbook_id
The playbook that authorised it, with the tool it permitted and your own workflow id.
authorised.checks
Each check that was satisfied, in the words of the rule it came from, carried inside the signed approval rather than reconstructed afterwards.
authorised.approved_by
Who approved it and when, next to the risk and the result that was expected.
happened.reported_outcome
What your system answered.
happened.outcome
What conxt is willing to stand behind. It reads did_not_match when an observation missed what was expected, whatever your system reported.
happened.unconfirmed
Anything nobody was able to check. Named, rather than left out.
complete
False unless both halves are there and nothing is unconfirmed. An incomplete receipt is not evidence and does not pretend to be.

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.

Read the governance model →
07 · Boundaries

What conxt cannot do

Worth knowing before you point this at production, because these are structural, not settings someone can talk their way past.

no standing access
conxt stores a signing secret for your connection. Never a key to your systems.
only declared fields
An undeclared field in a request cannot reach your endpoint.
only allowed actions
An action your connection does not list is refused before anything is sent.
public https only
Endpoints on private or internal addresses are rejected, and redirects are never followed.
signatures expire
A request older than five minutes should be rejected by your endpoint.
bounded answers
Twenty seconds, at most 64 KB, uncompressed. Your endpoint’s body is never echoed back.
owners only
Registering or re-pointing a connection is an owner action, and it is audited.
08 · Next

Other money fixes

The same six steps carry the rest of the queue. Change the action name, declare its parameters, and tick it on the connection.

apply_credit: an amount, capped in the playbook.
revoke_entitlement: the downgrade half of the same workflow.
close_account: high risk, and the approval card says so.

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.

hello@conxtagents.com →