- What it is
- Status
- Base URLs and terms
- Quick start: the seven rules
- 1. Register a client and get keys
- 2. Connect your users
- 3. Create a request
- 4. The signature request object
- 5. What is signed
- 6. Verify a signature yourself
- 7. What a verified signature proves, and what it does not
- 8. Keys over time
- 9. Webhooks
- 10. What the signer sees, and how they refuse
- 11. Limits, the audit log and errors
- 12. Test mode
- Downloads
PQC Imza integration guide (pqc-connect v2)
What it is
pqc-connect lets a company ask a PQC Imza account holder to sign something, on the phone. The request appears in the holder's PQC Imza phone app, the holder signs it there with a post-quantum signature made by the phone's key, and the company gets the signature back. v2 is the version in which the phone builds what it shows from the bytes it signs, and signs under a context of its own. That is what lets a company check a signature without taking PQC Imza's server at its word for one statement, under the conditions in section 7.
Status
v2 has been offered on production since 2026-10-31. Signing needs PQC Imza app 1.8.0, on Google Play to
everyone since 2026-11-01 and on the App Store since 2026-11-02; an older app is told
409 update_required (10.1). One client has used it: VirtualAI University, PQC Imza's own test client,
made two live test signatures, on 2026-10-31 and 2026-11-05, both on the phone of PQC Imza's owner. The
first was made with 1.8.0 on that phone before the release had reached everyone. As of 2026-11-06 no
other client has. Two AI reviewer agents of VirtualAI University, which has the same owner as PQC Imza, one
for application security and one for cryptography, reviewed the pqc-connect v2 contract on 2026-11-05 and
this guide on 2026-11-06. Each time both asked for changes first and passed the revised text. This is
academic review by AI agents: it is not an independent third-party audit, and it is not a certification.
Base URLs and terms
Base URL for signature requests: https://pqc-imza.com/api/connect/v2. Connect links, connections and
/client stay under https://pqc-imza.com/api/connect/v1. Every request and response body is JSON
(Content-Type: application/json), UTF-8.
| Route | Section |
|---|---|
POST /api/connect/v2/signature-requests |
3 |
GET /api/connect/v2/signature-requests/{id} |
4.1 |
GET /api/connect/v2/signature-requests?status=pending&limit=50&before=sr_... |
4.1 |
POST /api/connect/v2/signature-requests/{id}/cancel |
4.1 |
POST /api/connect/v2/signature-requests/{id}/simulate (test mode only) |
12 |
GET /api/connect/v1/client |
4.1 |
POST /api/connect/v1/connect-links |
2.3 |
GET /api/connect/v1/connect-links/{id} |
2.3 |
POST /api/connect/v1/connect-links/{id}/complete |
2.3 |
POST /api/connect/v1/connect-links/{id}/simulate (test mode only) |
12 |
GET /api/connect/v1/connections/{id} |
2.4 |
GET /api/connect/v1/connections?external_user_id=... |
2.4 |
POST /api/connect/v1/connections/{id}/revoke |
2.4 |
Words used here:
- client: a service that integrates with pqc-connect. It belongs to an organisation.
- signer: a PQC Imza account holder who is asked to sign.
- live and test: the two modes. Live requests reach real phones. Test requests never do (section 12).
An older version, v1, exists for earlier clients. New clients should not use it. In v1 the phone signs bytes the server chooses, so a compromised PQC Imza server could obtain a valid signature the signer never saw. If you run a v1 client, contact PQC Imza about moving to v2.
Quick start: the seven rules
- Compute the digest of the key you pin, yourself, from the 1,312 bytes, and have the signer compare all 64 characters on their phone. A pin that was not compared means the server can sign for that user. Every step below passes on a signature the server made alone with its own key. Keep with each pin the digest the signer compared: a pin whose digest is not that one has not been compared. Choose the pin by your own record of whom you asked, never by a field of a response.
- Keep your client's name as a constant: it cannot change after registration (1.2). Keep your organisation's name too, and record with each request the name it was created with: an administrator can rename an organisation at your request, and a request keeps the name it had. Rebuild from that record. Never read either name from a response.
- Send
client_nonceandexpires_aton every request. Both are required, and both are yours. Draw them once for each request, before its first attempt, and send them unchanged on every retry (section 3). - Rebuild the whole message from values you hold. From the server you take exactly two values,
idandcreated_at, from the creation response or, if you never received it, from an object whoseclient_nonceis yours and which passes section 3's comparison. Neither value means anything to a signer. - The context is a constant in your code:
pqc-connect/v2. Never read it from a response. Ifsignature.contextis present and differs, refuse. - Stop on any disagreement, before you look at a signature.
verify-connect-v2-signature.cjsdoes rules 4 to 6, and gives you the digest for rule 1. - Judge time by your own clock. Refuse a signature that reached you after
expires_at, or after the stamp you recorded for your own cancel (section 6, step 9). The script does not do this: it does not know when the signature reached you.
1. Register a client and get keys
A client is one service of an organisation. It has:
| Field | What it is |
|---|---|
id |
Public identifier, cli_ followed by 24 letters and digits. |
name |
The name shown to signers on the consent page and on the phone. 2 to 80 characters. |
| organisation | The organisation it belongs to, for life. Its owners and admins manage it. |
| registered by | The PQC Imza account that registered it. A record only: it decides nothing. |
| status | active or disabled. |
| live access | approved or awaiting approval (1.2). |
| webhook URL | Where results are sent (section 9). https:// only. |
| webhook secret | whsec_ followed by 40 letters and digits. Stored encrypted, because the server needs it to sign each webhook. |
1.1 Registering a client
An owner or admin of the organisation opens its developer area (1.5), enters the service's name and the webhook URL, and gets a client. A PQC Imza administrator can also register one. Both ways produce the same client. An organisation can register at most 5 clients on the website; an administrator can add more.
On creation the client gets one test key, one live key and a webhook secret. All three are shown once, on the page that created them, with a copy button and the warning that they will not be shown again. After that the server cannot show them: the keys are stored as a SHA-256 hash only, and the developer area never displays the stored webhook secret.
It carries its organisation's organization_id (3.8), which is not a secret and stays readable.
1.2 Live access needs an administrator's approval; test access does not
A new client can use its test key at once. Its live key is refused with 403 live_not_approved
until a PQC Imza administrator approves the client.
The reason: the client's name is what a signer sees on the consent page and on the phone as the party asking for a signature. Anyone can type "PQC Imza Security", a bank's name, or a university's name into a registration form. Consent (2.1) stops a client reaching people who have not accepted it, but the consent page itself would carry that name, and a convincing consent link is easy to send. Approval is where a person checks the name against the company before any phone sees it. For the same reason the name cannot be changed after registration: what a signer accepted stays what they see. A company that changes its name registers a new client.
The organisation's name is reviewed too, and first, because it is signed and shown to the same people: a client's live key works only with both reviewed, and a client cannot be approved until its organisation's name is.
The consent page (2.1) also refuses clients that are not approved, so an unreviewed name is never put in front of a signer.
1.3 Disabling
A PQC Imza administrator can disable a client. While disabled:
- every API call with any of its keys gets
403 client_disabled(or429 rate_limitedpast the key's limit, 11.1); - its pending live requests are cancelled at once (
canceled, reasonclient_disabled), so they leave the signer's phone; - no webhooks are sent to it (deliveries are recorded as
skipped); - its consent page refuses new consents.
Re-enabling restores API access. Cancelled requests stay cancelled.
A client also answers 403 client_disabled while its organisation is disabled, suspended or closed.
1.4 API keys
pqcc_live_<40 letters and digits>
pqcc_test_<40 letters and digits>
- The mode is part of the key. A test key can only create and read test requests; a live key only live
ones. A request made in one mode is
404 not_foundto a key of the other. - About 238 bits of randomness (40 characters from 62), from a cryptographic random source.
- Stored as
sha256(key)in hex, plus a visible prefix (pqcc_live_and the next 6 characters) so a key can be recognised in lists and logs. The full key is never stored or logged. - Sent as
Authorization: Bearer <key>. - A missing, malformed, unknown or revoked key all get the same answer,
401 invalid_api_key. - Rotation: issue a new key in the developer area, deploy it, then revoke the old one. Both work in between. At most 5 unrevoked keys per mode.
- Revocation takes effect on the next request: the key is checked against the database on every call, nothing is cached.
1.5 The developer area
https://pqc-imza.com/{locale}/organizations/{uuid}/developers, signed in, in every language the site
has.
- Register a client: service name and webhook URL. The test key, the live key and the webhook secret are shown once, with copy buttons and the warning.
- Keys: each key's prefix, mode, when it was made and last used; issue a new key (shown once), revoke a key.
- Webhook: change the URL, rotate the secret (shown once), send a
ping. - Requests: the latest requests in both modes; each one's page has its status, the signed message and signature when there is one, and the delivery log of every webhook attempt.
- Consent link to give to signers, and the client's audit events.
Only the organisation's owners and admins see or change its clients. Anyone else, a signer of the same
organisation included, gets 404 for every page and action, the same as for a client that does not
exist. So does a real client under another organisation's address.
2. Connect your users
A client may send live requests only to an account that has accepted that client. Without this, any company with a key could push requests to any account's phone. There are two ways in: the consent page (2.1), for clients that work by email address, and connect links (2.3), for clients that have their own users.
2.1 Consent
- The client sends the signer to its consent link:
https://pqc-imza.com/{locale}/connect/authorize/{client_id}(the developer area shows it). - The account's email must be confirmed. Signing in with Google or Apple confirms it. An address that was typed in and never confirmed proves nothing, and such an account can neither accept a client nor press Allow, and no request can reach it. Otherwise anyone could claim an address and sign for it.
- The signer signs in (Google or Apple, as usual) and sees the client's name, what accepting allows (sending signature requests to the phone), and what it does not (it cannot sign for them; every request needs their fingerprint, face or the phone's screen lock). They press Allow or Don't allow.
- Accepting is recorded once, per account and client. The signer can revoke it at any time on
https://pqc-imza.com/{locale}/connect(Connected services). - Revoking cancels that client's pending requests to that account (
canceled, reasonconsent_revoked) and they leave the phone. Every step on the phone also checks that consent still stands, so a withdrawal that races a create cannot leave a signable request behind. - Closing an account ends its consents, and cancels its pending requests with reason
account_closed. Thesigner_emailon its requests becomes one fixed value,closed@closed.invalid, in the webhook of that cancellation too. A request or a consent that lands in the moment the account closes is ended the same way by a scheduled job, with the same reason and the same webhook. That job runs every minute, so this is within a minute while the scheduler runs. If the scheduler stops, nothing bounds that minute, and the job catches up when it starts again. Nobody can sign such a request in the meantime: a closed account's sign-in is refused, whether it comes by a token, a session or a remember cookie, and a signing session opened before the closure stops working.
2.2 No account discovery
A request to an email address that has no account, to an account whose address was never confirmed, to an account that has not accepted this client (or revoked it), and to a closed account all get exactly the same response:
422 {"code": "signer_unavailable", "error": "..."}
The lookup is one query (consents of this client, joined to accounts with that confirmed email), so the cases take the same path. A client therefore learns only what it already knows: that a person it sent to its consent link accepted it.
2.3 Connect links: tying your user to an account
A client that has its own users can tie one of them to a PQC Imza account, and receive that account's key to pin, before it asks for any signature. The consent page of 2.1 stays, for clients that work by email address.
1. Make a link.
POST /api/connect/v1/connect-links
Authorization: Bearer pqcc_live_...
Content-Type: application/json
{
"external_user_id": "user-4821",
"state": "k3J9-a-random-value-kept-in-this-users-session",
"return_url": "https://example.com/pqc/return"
}
| Field | Rules |
|---|---|
external_user_id |
Required. Your own id for this user, 1 to 128 printable ASCII characters, no spaces. Opaque here: compared byte for byte, never interpreted, and never shown to anyone. |
state |
Required. 16 to 256 printable ASCII characters, no spaces. A fresh random value that you store in this user's signed-in session. |
return_url |
Required. Where the browser comes back to. https://, the rules of content_url (3.1), and no fragment. It cannot be changed afterwards. |
The answer is 201, and its body is flat. There is no wrapper: url sits at the top level beside
the fields of the link, so read id and url from the top of the body, not from a connect_link key.
These are all the fields:
{
"url": "https://pqc-imza.com/connect/link/<48 characters>",
"object": "connect_link",
"id": "lnk_7hK2mZ9qL4xR8tN1vB5cW3yD",
"mode": "live",
"status": "pending",
"external_user_id": "user-4821",
"state": "k3J9-a-random-value-kept-in-this-users-session",
"return_url": "https://example.com/pqc/return",
"created_at": "2026-10-30T12:00:00Z",
"expires_at": "2026-10-30T12:15:00Z",
"connection": null
}
object is always connect_link. GET /connect-links/{id} returns the same body without url. The
URL, https://pqc-imza.com/connect/link/<48 characters>, is shown this once, and only its hash is kept.
A link lasts 15 minutes and can be answered once.
2. Send your signed-in user there, by redirect, at once. The first browser to open the address holds the link, before anyone has signed in, and every other browser is closed out. So do not email it, post it in a chat or show it to anyone: a mail scanner or a preview that fetches it first closes it for the person, who then has to start again.
3. The person answers. They sign in to PQC Imza as usual and see the company's name and service ID,
which PQC Imza account is about to answer, what allowing means, and a warning to go on only if they
started this themselves on that company's site. They press Allow or Don't allow. Your
external_user_id is not shown.
Opening the link answers nothing. The first signed-in account to open it in that browser holds it; for any other account it is closed. An account whose email is not confirmed, or that has no proven key, cannot press Allow. The closed page is one page, with one status, for a link that is unknown, used, expired, for a test, or held by someone else.
4. The browser comes back. The person presses a link back to you:
https://example.com/pqc/return?connect_link=lnk_...&state=...&result=allowed&code=...
After Don't allow, result is denied, there is no code, and you also get a connect_link.denied
webhook.
5. Check, then complete. This check is yours, and it is the one that matters. Pressing Allow attaches nothing. Before you complete, confirm that:
- this browser's session is signed in to your site as the user you made the link for;
stateequals the value you stored in that session;connect_linkis the link you made.
Then, within 10 minutes:
POST /api/connect/v1/connect-links/{id}/complete
{"code": "..."}
Why. An attacker with an account at your company could start a link for their own user and send it to someone else, who presses Allow. That person's browser then comes back to you with no session belonging to the attacker, and your check stops it: you do not complete, the link runs out, nothing is attached, and no key leaves. PQC Imza cannot make this check for you, because only you know who that browser is signed in as.
If you skip the check, the result is bounded, not safe. No app code sends the key anywhere, and nothing is signed without the victim's fingerprint, face or phone screen lock. On an iPhone the key can travel in an encrypted backup. These do happen:
- the victim's public key is attached to the attacker's user;
- consent to your company is recorded, which they did not intend;
- up to 20 pending requests can reach their phone;
- anything they then sign is attributed in your system to the attacker's user.
They can undo it by withdrawing, and account_already_connected refuses it if they are already connected
to you. It does not stay contained in your records until they notice.
6. The result. complete returns the connection. The same object comes in a signed
connection.connected webhook, from GET /connect-links/{id} under connection, from
GET /connections/{id}, and from GET /connections?external_user_id=user-4821, which returns a list of
at most one. There is no way to list your connections, and no call reaches another client's. The url in
step 1 and qr_content use PQC Imza's fixed public address, whatever host your calls reach.
{
"object": "connection",
"id": "conn_7hK2mZ9qL4xR8tN1vB5cW3yD",
"mode": "live",
"status": "active",
"external_user_id": "user-4821",
"signer_address": "0x5c1f0e4b9a3d2c7e8f6a1b0d9c8e7f6a5b4c3d2e",
"public_key_base64": "...",
"connected_at": "2026-10-30T12:00:00Z",
"revoked_at": null,
"revoked_by": null
}
Those are all the fields. complete answers 422 invalid_code for a wrong code, and the right one still
works. It answers 409 with:
code |
Meaning |
|---|---|
link_closed |
Expired, answered and denied, already completed, never allowed; the account has since closed or lost its key; or the person withdrew consent after pressing Allow. Their last word stands. Make a new link. |
external_user_id_taken |
That user is connected to another account. A user id never moves by itself: revoke the connection first. |
account_already_connected |
This account is connected to you under another external_user_id. One account is one of your users, and the database enforces it. |
A link belongs to its client and mode. From another client, or with the other mode's key, it is
404 link_not_found, the same as one that does not exist.
The safety controls, in one place.
| Threat | Control |
|---|---|
| A person is sent a link made for someone else's user | Allow attaches nothing; the client's check in step 5 ends it |
| A forged form on another site | A CSRF token, and a one-time value on the page this session was shown |
| A session planted before sign-in | Signing in replaces the session |
| A browser signed in to an attacker's account by a cross-site form | PQC Imza's sign-in needs a CSRF token from a browser |
| An address someone typed and never proved | A confirmed email is needed to accept, to press Allow, and to be reached |
| Signed in to the wrong PQC Imza account | The page names the account about to answer |
| A link seen by someone else | The first browser holds it from the moment it opens the address, signed in or not, so a copy is second even while the person is signing in. The code goes only to return_url. The page sends no-store and asks for no-referrer; a cross-origin referrer carries no path either way, so nothing here relies on that header. The token is in the address, so web server logs hold it for the 15 minutes it can work. |
| A link or code used twice | Each moves its status once; a used link and an expired one get one page |
| A return address changed on the way | It is fixed at creation; nothing in the person's request can change it |
| Another client's link | Found only within the calling client and mode |
Allow, then withdrawal, then complete |
Refused: withdrawing and completing take one lock, and the withdrawal stands |
What this does not defend. The check in step 5 stops a victim's account landing on an attacker's user. It cannot stop the reverse, an attacker's account on a victim's user, if the attacker gets the link first:
- The attacker must have the link's address before the victim's browser opens it. A redirect normally rules that out, which is why step 2 says to redirect and never to send it.
- They open it, press Allow, and send the return address to the victim, who must click it while signed in
to your site. The right session,
stateand link then all arrive, and every check passes. PQC Imza cannot see this from the server, and neither can you. - Comparing the key on the phone, by the digest you computed (2.5), ends it, because the digest will not be the victim's. Until that comparison is made, a connection is as strong as the channel the link travelled through.
- An operator of PQC Imza's web server, who can read logs, sees a link's token for its 15 minutes. That operator is already trusted with the key this link delivers.
2.4 Connections
What a connection means. The company stays the authority on who its people are. A connection says that a person with this account pressed Allow on a link made for that user, and that the company then saw the right browser come back. It does not mean PQC Imza vouches for a name, a role or a membership. PQC Imza keeps no member directory for a client. No name, national ID or email address goes to a client through a connection, and consent means only that the person accepted the company.
After a connection. Name the signer by external_user_id instead of signer_email when creating a
request (section 3). It works only for an active connection of yours in that mode, on an account that is
still open and whose consent stands. Every other case gets the same 422 signer_unavailable as 2.2, so it
reveals nothing you did not know. The request object then has signer_email: null: the account's email
is not yours to learn this way.
When a connection ends. A signer who withdraws consent under Connected services, or closes their
account, ends it (revoked_by: "signer"), and pending requests are cancelled as 2.1 says. You can end one
yourself with POST /connections/{id}/revoke (revoked_by: "client"), for instance one you got wrong;
the consent itself stays. Either way you get a connection.revoked webhook. A revoked user id can connect
again through a new link, and keeps its connection id.
When the key changes. A new phone means a new key. You get a connection.key_changed webhook with the
new signer_address and key, and the connection's GET shows them. public_key_base64 is null if the
account has not yet proved the new key. A null never clears your old pin.
Do not move your pin on this webhook. It is PQC Imza's word, and a hostile server is what a pin protects you from. Requests go on opening with the new key, and signatures by it fail step 3 of section 6 against your old pin. That is correct: treat them as not yet accepted, confirm the new key as section 8 says, then change the pin. A new key needs the same comparison as the first one. A person who controls the account can always press Allow again, so reconnecting does not prove the key either.
2.5 Comparing the key digest
The client gets the full key before the first signature, with nothing asked of the signer. That closes the gap of a signer having no way to hand over their key. It does not change who you are trusting:
- The key comes from PQC Imza's server. A hostile server could return a key it holds itself, and every signature afterwards would verify against your pin. A pin taken this way is trust in the server until you check it some other way. Say so to anyone who relies on it.
- Do this much in any case: derive the address from the key (section 6, step 3) and compare it with
signer_address. That catches an error. It does not catch a lie, because both values came from one place. - What ends that trust is a comparison on the signer's phone, or another channel. The phone computes
its key's digest from its own stored key. You compute yours from the 1,312 bytes you are about to
pin, in your own code, and show that on your page. Have the signer compare all 64 characters with
the phone, or have them copy it to you so a machine compares it. The digest is the SHA-256 of the 1,312
decoded bytes, in lower-case hex with no
0x. Never display a digest the server sent you. A hostile server can return a key it holds beside the digest of the signer's real key, and the comparison would pass on the substituted key. That is why the connection object has no digest field. People compare the start and the end, and ten hex characters in all, such as five at each end, is within a hostile server's reach, at 2^40 key generations. The key itself is Base64 here and hex on the phone, so compare the digest, not the key's text. - A website is not a comparison. PQC Imza's profile page is the server. A comparison there proves nothing against the server.
- This covers every statement PQC Imza's server makes about the key:
connection.connectedandGET /connectionsas much as a webhook. Only the first pin may come from them, and keep it marked as not compared until it has been. - Silence proves nothing. A hostile server can simply send no webhook, so no webhook does not mean the key is still good. What tells you a key changed is step 3 of section 6 failing against your pin. Nothing tells you a key was stolen, except the signer (section 8).
A connection object has no public_key_sha256. A field a client must never use should not be there: a
hostile server could return a key it holds in public_key_base64, and the digest of the signer's real key
beside it. The address check passes, the digest matches the phone's, all 64 characters, the signer says
so, and the client has pinned the server's key. Compute the digest from the bytes you are about to pin,
and show that.
The key in a connection is still the server's word until it is compared. What ends that is the comparison
above, with your own digest. The phone's Show full key computes its digest from its own storage, so it
equals yours exactly when you hold this phone's key. Show full key shows the key's digest in groups of
four characters. Show yours the same way, so the signer compares like with like. The verifier script's
digest step (6.2) computes the digest from the 1,312 decoded bytes.
Do not build on a signed connection. A hostile server signs that statement itself with a key it holds, and a signature by a key proves only that someone holds that key. Only something that does not pass through PQC Imza can end first-use trust.
3. Create a request
3.1 The request
POST /api/connect/v2/signature-requests
Authorization: Bearer pqcc_live_...
Idempotency-Key: 4f1c2a9e-correction-14
Content-Type: application/json
{
"external_user_id": "user-4821",
"organization_id": "00000000-0000-0000-0000-000000000000",
"title": "Correction notice 14",
"content_sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
"content_url": "https://example.com/notices/14.md",
"reference": "notice-14",
"client_nonce": "7bT2-kQ9x_LmZ4pa",
"expires_at": "2026-11-06T12:00:00Z"
}
| Field | Rules |
|---|---|
signer_email |
The email of the signer's PQC Imza account (the Google or Apple address they sign in with). Compared without regard to case. Required unless external_user_id is sent; sending both is refused. |
external_user_id |
Your own user id, for a user connected through a link (2.3). Required unless signer_email is sent. |
organization_id |
Required, always. The nil UUID for a personal signature, or your organisation's ID. See 3.8. |
title |
Required. 1 to 200 characters. See 3.2. |
content_sha256 |
Required. The SHA-256 of the content, 64 hex characters. Upper case is accepted and stored lower case. |
content_url |
Required. Where the signer can read the content. https://, at most 2048 characters of printable ASCII, no spaces, no backslash, no user name or password (https://good.example@evil.example/ reads as the wrong host). Stored exactly as sent. |
reference |
Optional. The client's own identifier, at most 128 characters, under 3.2's rule for text. Returned, never shown to the signer, not signed. |
client_nonce |
Required. 16 to 64 characters from A-Z a-z 0-9 _ -. Sixteen characters is about 96 bits only if they are random and new, so draw it from a cryptographic random source once for each request, before its first attempt, store it with your record, and send the same one on every retry. Refuse a nonce that is already on another of your records. It is part of the idempotency comparison (3.4). |
expires_at |
Required, and in exactly this form: YYYY-MM-DDTHH:MM:SSZ, in UTC. An offset is refused, so that the line you rebuild is the string you sent. Between 10 minutes and 30 days from the time a request is created. Fix it once for each request, as the nonce. |
Idempotency-Key header |
Required. 1 to 128 characters from A-Z a-z 0-9 - _ . :. |
3.2 Text that is shown and signed
title and the client name are shown to the signer and are part of the signed message, so they are
refused (422 validation_failed) if they contain:
- a character of Unicode category Cc, Cf, Zl, Zp, Co, Cs or Cn: every control and format character (line breaks, ALM, LRM, RLM and all other bidirectional controls, the soft hyphen, the zero width space, the byte order mark, tag characters), line and paragraph separators, private-use and unassigned code points;
- a default-ignorable or blank character outside those categories. Known examples, not a closed list: U+034F, U+115F, U+1160, U+17B4, U+17B5, U+180B to U+180F, U+2800, U+3164, the variation selectors, U+FFA0, U+FFFC, U+16FE4, U+1D159, U+1D173 to U+1D17A;
- four or more spaces in a row, or five or more combining marks in a row.
ZWNJ and ZWJ (U+200C, U+200D) are allowed only between two characters of a script that needs them to spell words: Arabic, Devanagari, Bengali, Gujarati, Gurmukhi, Kannada, Malayalam, Oriya, Sinhala, Tamil and Telugu. A value must also contain at least one visible character.
Direction. The order a signer reads depends on the whole value, not on any one character:
Transfer ۔1000-2000, with an Arabic full stop, is stored as 1000-2000 and reads as 2000-1000. So a
value holding any character from the default right-to-left blocks (U+0590 to U+08FF, U+FB1D to U+FDFF,
U+FE70 to U+FEFC, U+10800 to U+10FFF, U+1E800 to U+1EFFF) must be right to left throughout. It must hold
a right-to-left letter. Beside right-to-left characters it may hold only ASCII digits, spaces, basic
punctuation, currency signs and combining diacritics, which is a list of what is allowed because no list
of left-to-right characters is complete. And it must not hold two numbers with no letter between them,
where U+0640 does not count as a letter. إشعار التصحيح 14 passes; Transfer ب1000 and
تحويل 1000-2000 do not.
A value that starts or ends with a space of any kind is refused, not trimmed. The API runs no trimming or empty-string middleware: the bytes the client sent are the bytes that are shown and signed, or the request is refused.
This does not stop look-alike letters, such as a Cyrillic а in a Latin word. In a title that changes nothing, since a client can write any title it likes. In a client name a person is the control: the administrator's page shows the alphabets in each name and flags a mix, a letter outside the alphabets it lists, and any fullwidth or styled form.
3.3 Compare before you store
After the response, compare before you store. client_id, client_name, organization_id,
organization_name, title, content_sha256 (in lower case), content_url, expires_at and
client_nonce must each equal what you sent or hold, and so must external_user_id when you named the
signer that way. signer_email must too, lower-cased as ASCII, as the server stores it, while the
request is pending: once the signer's account is closed, every read of any of its requests, signed or
ended, a replay included, carries closed@closed.invalid (2.1). If any differs, stop: cancel the
request, record why, and accept no signature on it. Against a hostile server the cancel proves nothing;
against an honest one it takes the request off the phone, where the signer could otherwise sign something
you will refuse. Keep id and created_at. A hostile server can write another name into the message and
hand the same name back, and a client that rebuilds from the response gets matching bytes.
3.4 Idempotency
The Idempotency-Key is unique per client and mode, and kept for the life of the request.
A key your client and mode have used already is answered from that request before the attempt is counted
against the create limit or validated, so a retry is answered by what the first attempt did (3.5). A
replay creates nothing new, and no second request reaches the phone. A different body, a malformed one
included, is 409 idempotency_conflict. A create of a new key that was refused (for example
signer_unavailable) stores nothing, so the same key may be used again once the cause is fixed.
"Same body" means the same signer_email (case-insensitive), external_user_id, organization_id,
title, content_sha256 (case-insensitive), content_url, reference, expires_at and client_nonce
as sent (absent counts as a value of its own).
3.5 Retries
Retry under the same Idempotency-Key, with the same body, the same nonce and the same expires_at. A
key your client and mode have used already is answered from that request before the attempt is counted
or validated (3.4):
- the same body is
200withIdempotent-Replayed: true, and the request as it is now, which may by then be signed, declined, expired or cancelled. It gets section 3's comparison like a first answer; - any other body is
409 idempotency_conflict. The request exists, under the body you sent first.
These answers come before the key is looked up, so they say nothing about an earlier attempt: 401,
403, 429 for the key or the address, 422 idempotency_key_missing, and any answer with no code
(section 11). Only 201, a replay or 409 speak for an earlier attempt, and no other answer means "not
created". Once you hold an id for a record, refuse an answer that names another id or created_at
for it. A request is created only while its expires_at is at least 10 minutes away, so leave a margin
before a first attempt; a replay is not held to that. If no answer ever came, take id and created_at
from a request you read later (a GET of the list, or a webhook) whose client_nonce is yours and which
passes section 3's comparison. Those two values are the server's word wherever they come from.
3.6 The response
201 Created with a signature request object (section 4). In live mode the request is on the signer's
phone from this moment: the next time the app's Sign tab loads, it is in the list. The object's
qr_content is what a client's page shows as a QR code (10.4).
3.7 Open requests per signer
At most 20 pending live requests per client and signer. More gets 429 too_many_open_requests. A phone
is not a queue.
3.8 Personal, or for an organisation
Every request says which, and the signer sees it before confirming.
organization_id is required on every request. There is no default. A missing value is refused, and
it never reads as personal: an unstated capacity is a mistake, not a preference.
| Value | Means | organization_name |
|---|---|---|
00000000-0000-0000-0000-000000000000, the nil UUID |
Personal: the signer signs for themselves. | The fixed word personal |
Your organisation's organization_id |
The signer signs for your organisation. They must be a member of it. | The organisation's name when the request was created |
The exact accepted form. A JSON string of 36 characters, lower-case, with hyphens, equal to one of
those two values. Everything else gets 422 validation_failed with an organization_id error: missing,
null, an empty string, 0, "0", false, upper case, no hyphens, braces, surrounding space, another
organisation's id, or a random UUID. The comparison is exact, so there is one way to write each value.
Your organisation ID. A random UUID (v4), given once when the organisation registers, which never
changes. Read it from GET /api/connect/v1/client or the developer area. All the clients of one
organisation carry the same one. It is not your client_id, and it is not an internal row number.
Membership. A live request with your organisation's id reaches only a signer who is an accepted member
of that organisation, and who has accepted this client as well: one does not replace the other. Anyone
else gets the same 422 signer_unavailable as 2.2. It is checked again whenever the phone is involved,
and a signer who leaves or is removed has their pending requests for the organisation cancelled
(membership_ended). Test mode looks up no account, so it checks no membership.
Why the word personal for the name, not an empty value. A value ending in a space is what a trimming
layer alters, and the message builder refuses an empty line on purpose. So personal has a fixed word, and
the two lines go together: the nil UUID with personal, or a real id with a real name. A verifier refuses
any other pair. Neither a client nor an organisation can register under the word for personal in any of
the app's ten languages, in any case or width, or with punctuation added. That stops the word and nothing
more: a name in look-alike letters, or a longer phrase, is not stopped, and from app 1.8.0 the
phone's layout is what tells the two states apart.
What it means. It is the capacity the signer accepted when they signed, and nothing more. It is not a
role PQC Imza vouches for. organization_id with your id says that this person was shown your
organisation's name and signed, and PQC Imza's server says they had accepted your organisation. It does
not show they may act for you, and nothing in the signature proves the membership. Whether they may is
yours to know, as 2.4 says of who your people are.
Where it appears. Two lines of the signed message (5.1), so a signature cannot be replayed under the other capacity. Also the request object, the webhook, the signer's page and the developer page. People always read "Personal" or the organisation's name, never the id alone.
4. The signature request object
4.1 Reading a request
GET /api/connect/v2/signature-requests/{id}
GET /api/connect/v2/signature-requests?status=pending&limit=50&before=sr_...
POST /api/connect/v2/signature-requests/{id}/cancel
GET /api/connect/v1/client
- A request belongs to the client and mode that created it. Any other client, or a key of the other mode,
gets
404 not_found, the same answer as for an identifier that does not exist. - The list is newest first, at most 100 per page;
beforetakes theidof the last request on the previous page. cancelworks on a pending request only (409 not_pendingotherwise). The request leaves the phone.GET /clientreturns the client'sid,name,organization_id,organization_name,status, whether live access is approved, and the mode of the key used.
4.2 The object
A signed request named by external_user_id:
{
"object": "signature_request",
"id": "sr_9QeV2mXo4b7TzL1wKc8yHn3d",
"mode": "live",
"status": "signed",
"client_id": "cli_Fh3kL9pQ2rS7tV1wX5yZ8aB4",
"client_name": "Example University",
"reference": "notice-14",
"signer_email": null,
"external_user_id": "user-4821",
"organization_id": "00000000-0000-0000-0000-000000000000",
"organization_name": "personal",
"title": "Correction notice 14",
"content_sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
"content_url": "https://example.com/notices/14.md",
"message_version": 2,
"client_nonce": "7bT2-kQ9x_LmZ4pa",
"qr_content": null,
"created_at": "2026-10-30T12:00:00Z",
"expires_at": "2026-11-06T12:00:00Z",
"signed_at": "2026-10-30T12:04:51Z",
"declined_at": null,
"canceled_at": null,
"cancel_reason": null,
"expired_at": null,
"signature": {
"algorithm": "ML-DSA-44",
"signed_input": "sha256-of-message",
"context": "pqc-connect/v2",
"message": "pqc-connect/v2\nenvironment: live\n...",
"message_base64": "cHFjLWNvbm5lY3QvdjIK...",
"signature_base64": "...",
"public_key_base64": "...",
"signer_address": "0x5c1f0e4b9a3d2c7e8f6a1b0d9c8e7f6a5b4c3d2e",
"classical": null
}
}
message_versionis2, andclient_nonceis the value you sent.signer_emailandexternal_user_idhold whichever named the signer, and the other isnull.signatureisnulluntil the request is signed.signature.messageis always the exact message that was signed.signature.contextis"pqc-connect/v2". It comes from the record, for people reading it. Your code does not use it, except to refuse if it differs.signature.classicalis alwaysnull. v2 has no classical half.qr_contentis a string while the request is pending andnullafter that (10.4).statusis one ofpending,signed,declined,expired,canceled. The last four are final.cancel_reasonis set only forcanceled:client(you cancelled it),consent_revoked(the signer withdrew their consent),client_disabled(your client or its organisation stopped),membership_ended(the signer left or was removed from the organisation) oraccount_closed(the signer closed their account).- All times are UTC,
YYYY-MM-DDTHH:MM:SSZ.
5. What is signed
5.1 The message
UTF-8, no byte order mark, fourteen lines joined by one line feed (0x0A), no final line feed:
pqc-connect/v2
environment: live
client-id: cli_Fh3kL9pQ2rS7tV1wX5yZ8aB4
client-name: Example University
request-id: sr_9QeV2mXo4b7TzL1wKc8yHn3d
created-at: 2026-10-30T12:00:00Z
expires-at: 2026-11-06T12:00:00Z
title: Correction notice 14
content-sha256: 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
content-url: https://example.com/notices/14.md
organization-id: 00000000-0000-0000-0000-000000000000
organization-name: personal
client-nonce: 7bT2-kQ9x_LmZ4pa
signer-address: 0x5c1f0e4b9a3d2c7e8f6a1b0d9c8e7f6a5b4c3d2e
Each line is the key, a colon, one space, and the value. No value can contain a line feed (3.2 refuses it), so the lines cannot be confused with each other. What each line holds:
- the first line is the literal
pqc-connect/v2; environmentisliveortest. A sandbox signature can never pass as a live one;client-idis the client'sid;client-nameis the client's name as it was when the request was created. It is what the signer was shown;request-idis the request'sid;created-atis the request'screated_at, UTC,YYYY-MM-DDTHH:MM:SSZ, andexpires-atthe request'sexpires_at, in the same form;titleandcontent-urlare byte for byte as sent;content-sha256is the digest, 64 lower-case hex characters;organization-idis the nil UUID for personal, or an organisation's UUID (3.8);organization-nameis the wordpersonalfor the nil UUID, otherwise the organisation's name as it was when the request was created;client-nonceis theclient_nonceyou sent;signer-addressis the signer's key address,0xand 40 lower-case hex characters (section 6, step 3).
signer-address is filled in when the signer opens the request on the phone, from the key their account
has registered at that moment. That key must be proven: its public key is on file, which only a signed
challenge or a verified signature puts there. If they register a different key before signing (a new
phone), that signature is refused (key_changed) and opening the request again builds the message with
the new address.
Where each value comes from, in a client's rebuild:
| Line | You take it from |
|---|---|
environment |
A constant: live in production |
client-id, client-name |
Your own configuration |
request-id, created-at |
The creation response, or rule 4's other source. These two only. |
expires-at, title, content-url, client-nonce |
What you sent |
content-sha256 |
What you sent, in lower case |
organization-id |
What you sent |
organization-name |
Your own record: personal for the nil UUID, otherwise your organisation's name as recorded with this request (quick start, rule 2). Not your client's name, unless the two are one string |
signer-address |
Derived from your pinned key (section 6, step 3) |
5.2 The signature
ML-DSA-44 as in FIPS 204, the pure variant, Algorithm 2, with M = SHA-256(message), the 32 raw bytes,
and the context string pqc-connect/v2, the ASCII of those fourteen characters. It is not
HashML-DSA. Signing is hedged, so two signatures of one message differ, and both verify.
signature_base64 decodes to 2,420 bytes and public_key_base64 to 1,312 bytes, standard Base64 with
padding. message_base64 is the message bytes, for clients whose JSON tooling might alter a string.
5.3 Separation from the key's other messages
Each purpose has its own context, and the phone chooses it from the bytes it parsed. A signature under another context does not verify here. That includes an empty-context signature a hostile server collected from an old phone over these exact fourteen lines, and a new phone does not sign with its signing key under an empty context at all.
6. Verify a signature yourself
6.1 What you need, and the steps
What you need: your own record of the request (whom you named, what you sent, the organisation name
recorded with it, and the id and created_at you kept), and the public key you pinned for the user
you named. Take that pin from your own record, never by a field of the response, and keep beside it the
digest the signer compared. A pin whose digest is not the compared one has not been compared (quick
start, rule 1). The fourteen lines name no user of yours, so the pin is the only thing that ties a
signature to the person you asked: a hostile server can deliver one user's request to another connected
user, and a client that picks the pin by the response files the second user's signature as the first's.
The steps. Stop and record nothing if any step fails. Ignore status: it is a hint about when to
look, not evidence. Strictly, for Base64, means the standard alphabet, with padding, no whitespace,
and decoded bytes that re-encode to exactly the text received. PHP's strict mode skips whitespace, and
Node's decoder skips characters it does not know, so check the re-encoding yourself.
- Find your record.
idmust be a request you created, and you must not already have recorded a sign-off for it. The pin you use is that record's user's. - Check the response against what you hold, as in section 3. Do this before looking at the signature.
- Check the key. Decode
public_key_base64strictly. It must be exactly 1,312 bytes and equal your pinned key, byte for byte. Derive the address as0xplus the last 20 bytes of Keccak-256 of those bytes, in lower case. This is Ethereum's Keccak-256, not SHA3-256. - Rebuild all fourteen lines from 5.1's table, with that address on the last line.
- Check the context. If
signature.contextis present and is notpqc-connect/v2, refuse. Why: a hostile server can still have a 1.7.1 phone sign these fourteen lines under the empty context, reportcontext: "", and a client that passes that field to its verifier gets a valid result. - Compare the message. Decode
message_base64and compare it, byte for byte, with your rebuild. - Decode the signature strictly. It must be exactly 2,420 bytes.
- Verify exactly one form. FIPS 204 ML-DSA.Verify (Algorithm 3) for ML-DSA-44, with your pinned key,
the constant context
pqc-connect/v2, andSHA-256(rebuilt message). Do not try another context, or none. - Check the time, by your own clock. Refuse the signature if it reached you after
expires_at, or after the stamp you recorded for your own cancel (section 8, condition 4). The server is trusted for timing (section 7) and can hold a genuine signature and deliver it late. - Check the content. Compute SHA-256 of the content you hold, and compare it with the digest you sent.
- Record the request, the message bytes, the signature, the key, and your own time of receipt. Date the sign-off from your receipt, because a signature proves no time.
6.2 The verifier script
verify-connect-v2-signature.cjs
does steps 2 to 8. It reads one JSON object from stdin, and it has no context argument and no way to read
your name from a response. It does not do step 9, because it does not know when the signature reached
you. It runs on Node 20.19 or later, which loads ES modules through require, with @noble/post-quantum
0.6.1 and either @noble/hashes 2.2.0 or 1.3.2, whichever resolves where you put it. On an older Node,
or with neither package, it refuses everything and says why. Copy it into your own code at a version
you have read, and never fetch it at run time: a copy you have read is your own code, which is what
condition (c) of section 7 asks. It is published with this guide, with the known answer of 6.3 and a
checksum file:
Its SHA-256 at this text:
dc79576d54518cbec68b47fac0a71982da33c59a984a69ef3d77b8df20ab1687 verify-connect-v2-signature.cjs
The script's comments were written against the contract this guide is drawn from, so the section numbers in them are the contract's: its 5.4 is section 6 of this guide.
Its input:
{
"step": "signature",
"own": {
"environment": "live",
"client_id": "cli_...",
"client_name": "Example University",
"organization_name": "personal"
},
"sent": {
"organization_id": "00000000-0000-0000-0000-000000000000",
"title": "Correction notice 14",
"content_sha256": "9f86...",
"content_url": "https://example.com/notices/14.md",
"client_nonce": "7bT2-kQ9x_LmZ4pa",
"expires_at": "2026-11-06T12:00:00Z"
},
"kept": { "id": "sr_...", "created_at": "2026-10-30T12:00:00Z" },
"response": { "...": "the signature request object, whole" },
"pinned_public_key": "<Base64, 1,312 bytes>"
}
It writes {"verified": true}, or {"verified": false, "reason": "..."}, and exits 0 only when
verified. own.environment must be live or test, and own.organization_name is the name recorded
with this request. A value holding a carriage return or a line feed is refused. Two other steps use the
same script:
"step": "creation", withown,sentandresponse: section 3's comparison, and the form of the two values you keep:idissr_and 24 letters or digits,created_atisYYYY-MM-DDTHH:MM:SSZ. It writes{"verified": true, "keep": {"id": "...", "created_at": "..."}}."step": "digest", withpublic_key_base64: writes{"digest": "<64 hex>"}, computed from the 1,312 decoded bytes. It reads no other field, so passing it a whole connection object is safe.
6.3 A known answer
app-v2-signature.json, published with
the script, is a signature by the app's own code over 5.1's example message, under pqc-connect/v2: the
key, the message's SHA-256 (089fe67898f24c38836084f03ff3d6697636412f24759a5664a657739c1e5fdb), the
context and the signature. Before you rely on your library, check that it verifies this under
pqc-connect/v2 and refuses it under the empty context and over the raw message. A library that
implements an earlier, pre-standard version of this scheme uses the same key and signature sizes, so the
length checks pass and only verification fails; this known answer is how you find out. In this file the
key and the signature are lower-case hex, not Base64: read as Base64, the key decodes to 1,968 bytes. The
message has no final line feed, so if you copy the example in 5.1 from this page, remove the line break
your copy adds at the end. The example's
signer-address line is illustrative and is not that key's address, so the fixture tests the primitive,
not the script.
The file's SHA-256:
76cbfacc40322aec977c0e56b6328f744e8d2c23035bc6ebea16c9d114080fbb app-v2-signature.json
7. What a verified signature proves, and what it does not
This is the claim, and it may not be quoted without its conditions.
A company need not trust PQC Imza's server for one statement: the holder of this key signed these exact bytes. That holds only if the company (a) computed the digest of the key it pinned itself, and the signer compared all of it on their phone or through a channel that does not pass through PQC Imza; (b) rebuilt the whole message from values it holds itself, including its own name and organisation name; and (c) verified under a context that is a constant in its own code.
If the genuine app was the only software that used the key, the phone showed the signer the fields of 10.1 from those bytes before signing.
This covers pqc-connect v2 only. It does not cover documents, or any signature under the empty context.
What the argument rests on. The holder of the pinned key's secret, or software with access to it, signed these bytes, provided the signature scheme of 5.2 is UF-CMA secure, which has a reduction to conjectured lattice assumptions, and SHA-256 is collision resistant, which is conjectured. The implementations are merely unbroken.
What you still trust, by name.
- PQC Imza as publisher of the app, for every release, including automatic updates. A signature does not say which build made it.
- The phone's operating system, and a key held in software behind a prompt the app's own code runs. A prompt can be passed with the phone's screen lock as well as a fingerprint or a face. It is not "only a biometric". There is no attestation: a rooted phone or a malicious build looks the same to you.
- PQC Imza's server for delivery, timing, status, declines and whether a request existed at all. It can withhold, delay or misreport anything. Silence proves nothing, and a decline carries no signature.
- Yourself, for who the person is. It is a key. No name, national ID or membership reaches you. "On behalf of" means the signer read that line. Membership is not checked.
What a signature does not show.
- That the signer read the content. They saw a title, a digest and a link. From app 1.8.0 they can have the phone fetch the link and compare it with the digest, on their own tap. That shows the link gave this phone a file with this digest, that once. Whoever controls the link's host can give a browser something else, so it does not show that what they read is that file. On a request your rebuild accepts, it shows your URL served your digest. On a request a hostile server forged it shows nothing, because that server chose both. It is optional, and you are not told.
- When it was signed. It was made after your request carried the nonce, and before you received it. Date it from your receipt.
- That the key is on one phone. On an iPhone it can travel in an encrypted backup, so a restored phone can hold a working copy while the original exists, and nothing tells you.
- That the key is still in the signer's control (section 8).
Your company's name on the phone is authenticated by nothing the phone can check. Administrator
approval and consent are controls on an honest server. A hostile server can show any company's name
with a well-formed client-id, and can deliver a request from a company the signer never accepted,
because the phone keeps no record of consent. Your own check stays safe: you have no matching record,
so that signature fails. Your name does not: it can be used to phish your people, with the app's seal
on the screen. Approved names protect you against other clients on an honest server, and against a
hostile one they are nothing. When its own record can be read, the phone marks a first request from a
company, and a known name under a new id; when it cannot, no mark appears. That is first use on the
phone. It is not authentication, and it needs requests signed by you, which v2 does not have.
8. Keys over time
8.1 A signature proves no time
signed_at is outside the signature, and created-at is supplied by the server. "A signature after
expiry is refused" is the server's promise. Date a sign-off by your own time of receipt, and refuse one
that reaches you after expires_at by your own clock.
Freshness. client-nonce is what lets you show a signature is fresher than your own request.
8.2 Keep a validity record for each pin
Record the key, when and how it was confirmed, and when it stopped being valid. Accept a signature only from a key whose record was open at your time of receipt. There is one exception, in 8.3.
8.3 Pinning from a signature you hold: the one exception to an open record
8.2 accepts a signature only from a key whose record was open at your time of receipt, and a pin set from a signed request is set after receipt. That is allowed, for that one signature, within two limits and on four conditions.
- Only a
pqc-connect/v2signature. An empty-context signature gets no benefit from this, whatever the comparison says: a 1.7.1 phone can sign a v1 request, update, keep its key and pass the comparison afterwards, and nothing parsed what it signed. - Only a key you hold no record for, for any of your users. It never reopens a closed record. A key you closed stays closed, whatever signature arrives under it.
- The time belongs to those bytes. Record your time of receipt when they arrive, and keep them. Never read the server again and stamp what it says now with the earlier time.
- All of section 6, then all of the digest, before you pin. Steps 1 to 8 of section 6 pass on this signature, with this key in the place of the pin. Then compare your own digest of it, all 64 characters, on the signer's phone or through a channel that does not pass through PQC Imza. A comparison that fails refuses the signature: record no sign-off, and do not pin. Keep what arrived, because a failed comparison is the one sign you get of a substituted key. Steps 9 to 11 of section 6 follow a comparison that passed.
- Record both dates: when it was received, and when the key was compared.
- Refuse it if it reached you after
expires_atby your clock, or after you cancelled the request. A cancel starts at the stamp you record for it, and not when someone pressed a button. A signature that reached you before that stamp is not refused by this, even one made after the press, because a signature carries no time and the honest race looks the same. Say so where the person presses it.
The signature that revealed a new key may use this, on the same limits and conditions, because the argument does not depend on the signer being new. A renewed ID card makes a new key, so every client meets this. Your old pin fails at step 3; if the new key is one you hold no record for and it passes all four conditions, you may pin it and accept that signature, and you close the old record yourself.
No second signature is needed. A server signing with its own key can sign twice as easily, and a v2 signature that verifies under a compared pin means the same whichever came first. A connection delivers the key before any signature, so compare, pin, then sign is always open to a connected signer. Prefer it: the key from a connect link or a first signed request is the server's word either way, until compared.
8.4 A new key
The account registers it, and pqc-connect opens later requests with it. A client with a connection gets a
connection.key_changed webhook as a hint (2.4); a client without one gets nothing, because key_changed
is an internal refusal. Neither moves a pin.
A new key always needs a new comparison. A new phone, a reinstall or an ID card read again makes a new key. The app warns the signer before an ID card replaces a key, and that is all it does. v2 has no signed rotation statement in this release. So the old key says nothing about the new one, and your pin fails at step 3. That is correct. Treat it as not yet accepted until the exception above is met, compare again with your own digest, and never relax the check because it happens often: a client that does has given up condition (a). Renewing an ID card is ordinary, so expect this. Then add the new key to your record and close the old one.
8.5 A lost or compromised phone
Revocation is the signer's act and the client's record: the signer tells each client out of band, and the client closes the pin. PQC Imza refuses a key the account has replaced, but a client must not rely on that, and a lost phone's key stays valid for a pinned client until the client closes it.
9. Webhooks
When a request becomes signed, declined, expired or canceled, a webhook is sent to the client's
URL. The developer area can also send a ping.
9.1 The request
POST <webhook URL>
Content-Type: application/json
User-Agent: pqc-connect/1
PQC-Connect-Event: signature_request.signed
PQC-Connect-Delivery: evt_3kP0qW8eR2tY6uI9oA1sD5fG
PQC-Connect-Signature: t=1793448291,v1=5d41402abc4b2a76b9719d911017c592...
{"id":"evt_3kP0qW8eR2tY6uI9oA1sD5fG","type":"signature_request.signed","mode":"live","created_at":"2026-10-30T12:04:52Z","data":{ ...the signature request object... }}
Event types: signature_request.signed, signature_request.declined, signature_request.expired,
signature_request.canceled, ping, and for connections connection.connected, connection.revoked,
connection.key_changed (each with the connection object) and connect_link.denied (with the link
object).
9.2 The signature on a webhook
v1 = hex( HMAC-SHA256( key = webhook secret, data = t + "." + raw body ) )
- The key is the whole secret string (
whsec_...) as UTF-8 bytes. tis the Unix time in seconds when this attempt was sent, as decimal digits.- The raw body is the exact bytes received, before any JSON parsing.
The receiver must:
- parse
tandv1from the header; - recompute the HMAC and compare it with
v1in constant time; - refuse the delivery if
tis more than 300 seconds from its own clock, in either direction. Becausetis inside the signed data, an attacker who captured a delivery cannot move it into the window by changing the header; - keep the
idof every event it has processed and ignore a repeat (retries carry the sameid); - for
signature_request.signed, still check the signature itself (section 6). The webhook says something happened; the signature proves it.
9.3 Delivery and retries
- Sent from a queue, on its own worker, so a slow endpoint cannot hold up PQC Imza's other work, and never from the request that caused the event. Test webhooks are sent only when no live webhook is waiting, because test traffic is free.
- At most 10 jobs per client wait at once. Further events stay due, and the sweep (every minute) queues them as room opens. Nothing is dropped; they arrive later.
- An attempt is claimed and counted before it is made, so jobs that pile up during an outage cannot spend the retries at once, two workers cannot send one attempt, and a worker killed mid-attempt still uses one up.
- A 2xx answer within 10 seconds is success. Anything else, including a redirect, is a failure. Redirects are not followed.
- Up to 8 attempts: at once, then after 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and
24 hours. Each attempt is signed afresh, with its own
t. After the eighth failure the delivery isfailed; the request is still readable withGET. - Every attempt is logged (time, HTTP status or error) and shown in the developer area under the request. How long an attempt took is not recorded, so the log cannot be used to scan ports.
- The URL must be
https://, on port 443 or 8443, with no user name or password. Its host is either a DNS name of at least two labels (letters, digits and hyphens, no trailing dot) or a public IP address in canonical form. A host whose last label is a number, or starts with0x, counts as an address, so0177.0.0.1,2130706433and127.1are refused. - Before each attempt the host is resolved and every address must be a public one (not loopback, private,
link-local, carrier-grade NAT, documentation, multicast or reserved; for IPv6, inside
2000::/3and outside 6to4, Teredo and documentation space). The connection is then forced to the address that was checked, and the address it actually used is compared afterwards; a difference is a failed attempt (address_mismatch). This keeps a webhook URL from being used to reach PQC Imza's own network. - The answer's body is discarded as it arrives.
9.4 Rotating the webhook secret
The developer area makes a new secret, shows it once, and signs every later attempt with it, retries of earlier events included. There is no overlap period: install the new secret first if you cannot afford to refuse a delivery, since refused deliveries are retried.
10. What the signer sees, and how they refuse
10.1 On the phone
A request is opened from the app's Sign tab or from a QR code on your page (10.4). After that both are the same.
The phone shows no file. The server sends no document for a v2 request, and the phone fetches no file. So no file the server chose reaches a renderer on this route.
The phone shows these, all parsed from the signed bytes, and no server text:
- in a fixed block above Sign, which starts on screen: Signing as, which is Personal or On behalf of and the organisation's name, then From, your client's name, with the first-use mark. A very long name makes the block scroll within itself, and Sign stays off until all of it has been on screen;
- in the list: Title, Content SHA-256 in full, Link, Client, Request, Key (the signer address in full,
0xand 40 hex characters, with Show full key for the key itself and its SHA-256), Expires.
Sign stays off until the block and the list have been scrolled to their ends. environment,
client-nonce, created-at and organization-id are signed and not shown. No identity is shown or sent
on a connect signature. The signer then confirms with a fingerprint, a face or the phone's screen lock.
Show full key shows the key's digest in groups of four characters. Show yours the same way, so the signer compares like with like.
An old phone cannot sign v2. App 1.7.1 is told 409 update_required when it opens the request, and the
request stays pending. A new phone cannot sign a v1 request (409 reissue_required), so reissue it under
v2.
10.2 Declining
From app 1.8.0 there is a Decline button on the phone. The request ends declined, and the webhook
is sent. The web page's Decline stays as well. A decline is the server's word: it carries no signature.
The web page is the request's own page on the PQC Imza website, where the signer is signed in.
10.3 Expiry
A request not signed by expires_at becomes expired (checked every minute, and at once whenever the
request is read or opened). It leaves the phone, and the webhook is sent. The server refuses a signature
that arrives after expiry; the signature itself proves no time (section 8).
10.4 A QR code
From 1.8.0 the app reads a connect code with its own scanner. qr_content is built by the server and
holds a request id, so a code on your page is a pointer: the phone opens the request it names for the
account signed in on it, and refuses to sign if the request-id in the signed bytes is not the one it
scanned. That compares one value the server chose. It does not show that the signer approved what your
page asked for: that is your rebuild's job.
The same request is in the app's Sign tab as well, so a signer who does not scan loses nothing.
What the client gets. Every signature request object has qr_content, a string while the request is
pending and null from the moment it ends. Before you draw it, check that it is exactly
https://pqc-imza.com/connect/sign/ followed by the id you kept, and draw nothing if it is not: the
code is on your page, and an ordinary camera opens whatever address it holds. Then put exactly that
string in a QR code:
https://pqc-imza.com/connect/sign/sr_9QeV2mXo4b7TzL1wKc8yHn3d
Do not add a query string, a fragment or a tracking wrapper: the phone compares the whole string with the
address compiled into it and refuses anything else, which is why qr_content always uses PQC Imza's fixed
public address. Use error correction M or better, dark on light, with the standard quiet zone, at least
3 cm across on a screen.
What it contains. The address of the service and the request id, and nothing else: no secret, no title, no name, no key. The request id is not sensitive; it is inside the signed message. The code proves nothing and approves nothing, so it does no harm if someone else sees it. What protects the request is the signer's own signed-in account, then their key, then their fingerprint, face or phone screen lock.
How long it lives. As long as the request: until expires_at, or until it is signed, declined or
cancelled. A code does not need refreshing, and has no lifetime of its own. Stop showing it when
qr_content is null.
What happens when it is scanned.
| Scanned by | The request | Answer to the phone |
|---|---|---|
| The named signer | Pending | A session, the same one the Sign tab opens. The phone shows what is in 10.1. Nothing is signed until they confirm. |
| The named signer, again | Pending | Another session. The first signature to arrive settles the request, and every other session closes. |
| The named signer | Signed, declined, expired or cancelled | 410, "code": "not_open", and status saying which. An expired request is noticed at the scan, without waiting for the minute's sweep. |
| The named signer | Pending, but the client is disabled or consent was withdrawn | 409, "code": "not_open". |
| The named signer, no proven key | Pending | 409, "code": "no_signing_key". |
| Any other account | Any state, a test request, or no such request | 403, "code": "wrong_key", the same bytes in every case. No session, no audit event, and nothing to the client. |
| Nobody signed in | Any | 401. |
| One account, more than 20 scans in a minute | Any | 429. |
So a stranger holding a code learns nothing: not whether it is real, whether it has ended, or whose it
is. A signature by the wrong key for the right account is still refused at submission (wrong_key), so a
scan does not widen what any key may do.
What a code does not change. Section 7 reads the same for a scanned request as for a listed one. The client checks the signature exactly as in section 6. And the phone cannot know which page showed the code: any site could show it. The signer must read the phone, not the page. The phone shows the client's name and id from the request.
11. Limits, the audit log and errors
11.1 Rate limits
| What | Limit | Over it |
|---|---|---|
| Any call, per API key | 120 a minute | 429 rate_limited |
| Creating requests, per client and mode, not counting a replay (3.4) | 30 a minute | 429 rate_limited |
| Creating connect links, per client and mode | 60 a minute | 429 rate_limited |
| Pending live requests, per client and signer | 20 | 429 too_many_open_requests |
| Calls with an invalid key, per IP address | 30 a minute | 429 rate_limited |
| Developer-area changes, per account | 20 a minute | the page says so |
| A person's answers to a company (accept, deny, withdraw, a link, a decline), per account | 20 a minute, counted on their own | 429 |
A 429 carries Retry-After in seconds. Past 30 calls with an invalid key in a minute from one address,
every further call from that address with a missing, malformed, unknown or revoked key gets
429 rate_limited instead of 401 until the minute ends. A valid key is never held back by its address.
It is looked up first and the address count never applies to it, so neither a neighbour on a shared
address (a NAT, a CI provider, a hosting platform) nor a revoked key left on one of your machines part way
through a rotation (1.4) can block your valid calls. A 429 on a valid key is therefore one of the other
limits above.
The per-key limit counts every call made with a valid key, including a call refused with
403 client_disabled (1.3) or 403 live_not_approved (1.2). Past 120 of them in a minute, that key gets
429 rate_limited too, until the minute ends.
11.2 The audit log
Every change is written to an audit log: who (an API key by its prefix, an account, an administrator or PQC Imza's own command line, the signer, or the system), what, which client and request, the IP address, and when. Never a key, a secret, or a signature.
Recorded: client.created, client.approved, client.disabled, client.enabled, key.created,
key.revoked, webhook.url_changed, webhook.secret_rotated, webhook.ping, consent.granted,
consent.revoked, request.created, request.idempotent_replay (at most once a minute for each
request), request.opened (the phone opened it), request.signature_refused (with the reason),
request.signed, request.declined, request.expired, request.canceled, request.simulated,
link.created, link.allowed, link.denied, connection.connected, connection.revoked,
connection.key_changed.
The developer area shows a client its own settings events and what the API already tells it about a
request, with the actor as a role (signer, account, administrator), never an account id. It does not
show consents, request.opened or request.signature_refused: when a signer looked, and what went wrong
on their phone, are not a client's to know. A PQC Imza administrator sees everything.
11.3 Errors
{"code": "signer_unavailable", "error": "The signer cannot receive requests from this client."}
code is for programs and does not change; error is English text for a developer reading a log.
Validation failures add "errors": {"field": ["..."]}.
An answer with no code did not come from pqc-connect's own checks: 404 for a path that does not exist
(/connect-links under /api/connect/v2, for instance), 405, 413, 500, or 503 with
Retry-After while a deploy runs. Treat it as no answer.
| Status | code |
|---|---|
| 401 | invalid_api_key |
| 402 | insufficient_credits: a live create, only once PQC Imza charges organisation credits for pqc-connect and the organisation's credits are used up. Not retried by the server; an administrator adds credits |
| 403 | client_disabled, live_not_approved |
| 404 | not_found, link_not_found, connection_not_found |
| 409 | idempotency_conflict, not_pending, link_closed, external_user_id_taken, account_already_connected, not_active |
| 422 | validation_failed, signer_unavailable, idempotency_key_missing, invalid_code |
| 429 | rate_limited, too_many_open_requests |
12. Test mode
Test keys work the moment a client is registered, before any approval, and a test request never reaches a phone:
-
No account is looked up and no consent is needed;
signer_emailis stored and shown in results, nothing else. -
Two addresses are reserved, so a client can test those answers:
signer-unavailable@example.comgets422 signer_unavailable;insufficient-credits@example.comgets402 insufficient_credits, the answer a live create gets when the organisation's credits are used up (PQC Imza'sdocs/pqc-connect-v1.md, §9 and §11.1). It answers this way whatever the organisation's balance, and only with a test key.
-
The client decides the outcome:
POST /api/connect/v2/signature-requests/{id}/simulate {"outcome": "signed" | "declined" | "expired"}signedproduces a real post-quantum signature over the 5.1 message (withenvironment: test) by the client's sandbox key, a key pqc-connect generates for each client and keeps encrypted. It belongs to no account and no phone. The result and the webhook are exactly what live mode sends, so the client's verification code (section 6) can be tested end to end. -
expiredmoves the request'sexpires_atto the moment you call it, so the object'sexpires_atthen differs from what you sent. A live request's never changes. A client that compares every object it reads (section 3) will refuse a simulated expiry for that reason; expect it in your tests. -
simulatewith a live key, or on a live request, is404 not_found. -
A test connect link runs without a person. Its page is closed to everyone. Call
POST /api/connect/v1/connect-links/{id}/simulatewith{"outcome": "connected" | "denied" | "expired"};connectedreturns thecodea browser would have carried, socompletecan be tested. The connection carries the sandbox key.simulateon a live link is404 link_not_found. -
organization_idfollows the same rules in test mode (3.8), except that no membership is checked, because no account is looked up. -
A test request may name a test connection by
external_user_id, and any other id gets422 signer_unavailable, so that answer can be tested too. -
A test request has a
qr_content, so that a page can be built and tested. No phone can use it: a scan is refused exactly as someone else's request is (10.4), and the camera's address ends at a 404. -
Webhooks are sent for test events too, with
"mode": "test".
Downloads
| File | What it is | SHA-256 |
|---|---|---|
| verify-connect-v2-signature.cjs | The verifier script (6.2) | dc79576d54518cbec68b47fac0a71982da33c59a984a69ef3d77b8df20ab1687 |
| app-v2-signature.json | The known answer (6.3). Its message's SHA-256 is 089fe67898f24c38836084f03ff3d6697636412f24759a5664a657739c1e5fdb |
76cbfacc40322aec977c0e56b6328f744e8d2c23035bc6ebea16c9d114080fbb |
| SHA256SUMS | A checksum file |
The script runs on Node 20.19 or later, which loads ES modules through require, with
@noble/post-quantum 0.6.1 and either @noble/hashes 2.2.0 or 1.3.2, whichever resolves where you put
it. On an older Node, or with neither package, it refuses everything and says why. Copy it into your own
code at a version you have read, and never fetch it at run time.
The checksums are served from the same site as the files, so they catch a damaged download, not a compromised site. To guard against that, compare the digests with PQC Imza through another channel before you rely on the files.