Guide — Signing when you hold the key
The pattern almost every real integration uses. The gateway computes what must be signed; you sign it; you hand the signature back. Your private key never leaves your process, and the gateway can never act without you.
This is called external mode. The alternative — provider mode, where the gateway holds a key and signs for you — is fine for a demo and wrong for anything holding value.
you gateway Accumulate / target chain
─── ─────── ─────────────────────────
POST /v1/transaction ─────────────▶ builds the intent
◀──────────── signing_data.hash_to_sign
sign it locally
POST …/signature ─────────────────▶ submits the envelope ──────▶ authorized, then executed
◀──────────── tx_hash + proof_id
Prerequisite: an identity created with public_key (see onboard-an-identity.md).
An identity registered with only a public_key_hash cannot sign — that combination used to be accepted at
create and rejected at sign time, so check can_sign on the identity before you build anything on it.
export CERTEN_API_URL="https://gateway.kompendium.co"
export API_KEY="ck_live_..."
1. Open the intent
curl -X POST $CERTEN_API_URL/v1/transaction \
-H "X-API-Key: $API_KEY" -H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"identity_id": "<uuid>",
"intent": {
"fromChain": "accumulate",
"toChain": "ethereum-sepolia",
"fromAddress": "acc://your-org.acme",
"toAddress": "0xBe00...9251",
"amount": "4000",
"tokenSymbol": "ETH"
}
}'
201 {
"intent_id": "…",
"status": "signing_required",
"signing_mode": "external",
"signing_data": {
"request_id": "…",
"transaction_hash": "…",
"hash_to_sign": "9c2b…" // 32 bytes of hex — THIS is what you sign
},
"submit_url": "/v1/transaction/<intent_id>/signature"
}
Send an Idempotency-Key. A network error on this call is otherwise indistinguishable from success, and
retrying without one can open a second intent. A replay returns the original with "idempotent": true.
2. Sign hash_to_sign locally
Ed25519 over the raw bytes of the hex string — do not hash it again, and do not sign the ASCII.
import nacl from 'tweetnacl';
const sig = nacl.sign.detached(Buffer.from(hashToSign, 'hex'), secretKey); // 64 bytes
const signature = Buffer.from(sig).toString('hex'); // 128 hex chars
3. Hand the signature back
curl -X POST "$CERTEN_API_URL/v1/transaction/<intent_id>/signature" \
-H "X-API-Key: $API_KEY" -H "Content-Type: application/json" \
-d '{"signature": "<128 hex>", "public_key": "<64 hex>"}'
The gateway verifies the signature against public_key locally before submitting, so a bad signature is
a 400 from the gateway rather than a confusing 502 from downstream. A transient downstream failure
releases the sign request so you can retry rather than stranding it.
4. Wait for the proof
Execution is not instant — a real cross-chain proof cycle runs ~60–110 seconds. That is validator work, not a configurable delay.
curl "$CERTEN_API_URL/v1/transaction/<intent_id>" -H "X-API-Key: $API_KEY"
Poll that, or subscribe to webhooks and stop polling. When it carries a proof_id, see
verify-a-proof.md.
Choosing which key signs
Two optional fields on POST /v1/transaction matter once more than one key is involved:
| Field | Use it to |
|---|---|
signer_public_key (64 hex) |
Nominate which seat on the key page signs. Defaults to the identity's bound key. On an M-of-N panel this is how any seat opens a transaction — an agent proposes, a human finalizes. |
signer_key_page (acc://org.acme/book/2) |
Nominate which page of the book signs. Must be in the same book. Lower index = higher priority, and Accumulate authorizes at book level, so a high-priority page can satisfy the book alone — that is how you separate routine work from escalation. |
hash_to_sign is bound to whichever key you nominate, so the signature you post must come from that key.
signer_public_key cannot be combined with a provider-signed identity.
Failure modes worth handling
| Symptom | Cause |
|---|---|
400 "Identity has no public key configured" |
The identity was created with only public_key_hash. PATCH /v1/identity/:id can repair it if the hash matches. |
400 on /signature |
Signature does not verify against public_key. Usually the ASCII of the hex was signed instead of its bytes. |
409 on /signature |
Already signed. Treat as success and read the intent. |
| Second intent appeared after a retry | No Idempotency-Key on step 1. |
Full request and response schemas: https://gateway.kompendium.co/reference.