Inspiration

An invoice arrives from a supplier you have worked with for two years. Same letterhead, same layout, same number sequence. One line is different: the bank account. Somebody intercepted the real invoice, changed that line, and forwarded it on. Everything else is genuine, because it is genuine, apart from where the money goes.

  • 3.05 billion dollars lost to this in 2025
  • 122,000 dollars average per report
  • 86 percent moved by wire or ACH, so it was gone before anyone noticed

FBI Internet Crime Complaint Center, 2025 annual report.

The control finance teams actually use is a phone call: when bank details change, somebody rings the supplier on a number they already had. It works. It does not scale, it gets skipped under deadline pressure, and it fails outright when the person answering the phone is the one who was compromised.

The software meant to replace that call inspects the document and guesses at fonts, compression artefacts, layout. That was a reasonable bet when forging an invoice was hard. It is no longer hard, and the forger has a copy of the same tool. By May 2026, 71 percent of fake receipts that expense platforms flagged were machine generated, against zero fourteen months earlier (AppZen).

Both approaches ask does this look genuine? That question is now unanswerable, and getting worse. So we built something that asks a different one.

What it does

Signet asks: did the sender say they sent this?

  • A company publishes one public key as a DNS record on the domain they already own.
  • Every invoice they issue carries a signature over the fields that decide where money goes: account number, amount, currency, bank code, invoice number.
  • Anyone receiving it can check that signature against the sender's own domain, with no account and nothing installed.

The printed signature block is a QR code carrying the signed fields and the signature over them. We call it the mark.

Check the trust anchor yourself, right now, without our code:

$ dig +short TXT _signet.northpost.dev
"v=SIGNET1; k=ed25519; p=Kqf0GFh29fslh099Tr9ruRvy6qI7ITeKC5KY8Wt0YWI="

Published by whoever controls northpost.dev and nobody else. No certificate authority, no company in the middle, no account with us. The walkthrough that ends in openssl printing Signature Verified Successfully is in docs/verify-by-hand.md. We wrote it by running it.

Try it in about a minute

The check page hands you the documents, so trying it needs nothing from the repo. Open the live link and take one of ours:

Document Verdict Why
A genuine invoice CERTIFIED signed by the domain the brand signs from, and the page matches
One with the account changed FLAGGED the page and the signature disagree on the account number
One from a lookalike domain FLAGGED it names Northpost, but north-post.dev is North Post Holdings

Each sample is signed at the moment you ask for it. A static file would work exactly once, because the ledger records every document ever checked and the first visitor would spend it.

The third is the interesting one. Nothing about it is forged: a real domain, a real key, a real signature, every cryptographic step passing. The name is the forgery, and a signature alone cannot see it.

Then do this, because it is the case only live search can decide. Take the lookalike sample again and type Maersk into the brand field instead. Nothing about the document changes. The verdict does:

pass     signature     Issued by north-post.dev on 2026-09-03T08:30:46+00:00.
fail     identity      This document names Maersk, but north-post.dev is North Post Holdings.
unknown  lookalike     No enrolled domain to compare against for Maersk.
unknown  domain_age    north-post.dev was registered 12 days ago, recent enough to be worth a look.
pass     fidelity      The page matches what was signed.
fail     counterparty  The open web publishes maersk.com for Maersk, and this was
                       signed by north-post.dev.
pass     duplicate     Not seen before.
VERDICT: FLAGGED

Maersk has never heard of us and never will. Watch what our own registry can and cannot say about that. Identity refuses, but only generically: it knows north-post.dev is not Maersk because it holds a different brand record for it, and against a domain we had never seen it could say no more than "not a domain we hold a record for". Lookalike goes quiet outright, because it has no enrolled Maersk domain to compare against.

Counterparty is the only line that names the company's real domain, and it gets it from SerpApi at the moment you ask. That is the difference between refusing a document and telling somebody where the invoice should have come from.

How we built it

Seven checks run in order. Each answers one question, reports its own outcome, and carries its own evidence. These are the names the system prints, so a trace above or in the repo lines up with this table.

Check Question
signature Does the key at the sender's domain verify this signature over these exact fields?
identity Is that domain the one enrolled for the brand the document claims?
lookalike Does the signing domain read as somebody else's while not being theirs?
domain age How long has the signing domain existed?
fidelity Does the page in front of you show what the signature actually covers?
counterparty What does the live web publish for this brand, and does it agree?
duplicate Has this exact document been presented here before?

Four decisions we would defend:

  • The verdict is a pure function. decide(signals) has no model, no clock, no network, no state. Same signals in, same verdict out, every time. It has a golden test suite rather than a confidence score, because evidence you cannot replay is not evidence.
  • A check that cannot reach its source reports unknown, never pass. An outage lowers a verdict instead of inventing one, and an outage is exactly when somebody would choose to send a lookalike.
  • Publishing a key to DNS is the only irreversible act, and no agent can reach it. A broker publishes, and only after reading a countersigned document back.
  • The domain never imports a vendor. Ports and adapters, with a lint rule that fails the build otherwise. That is why the pipeline runs in tests against fakes with no credentials, and a fork with no keys gets the same green tick we do.

Where each sponsor does the heavy lifting

Nutrient DWS

Nutrient's Data Extraction API reads the page as it actually arrived and returns every payment field with a confidence and a bounding box, which is what lets us compare paper against signature and route doubtful cases to a person instead of guessing.

This is the check that catches the fraud in the problem statement. The signature covers the true account number; the doctored page shows a different one. Only comparing the two finds it, and that needs the page read as an image, because what arrives is a photograph or a scan, not a data feed.

One live extraction of the doctored sample:

amt   '15,580.00'                confidence=0.95  box=(0.842,0.284,0.069,0.009)
bic   'NWBKGB2L'                 confidence=0.95  box=(0.235,0.377,0.078,0.008)
cur   'USD'                      confidence=0.95  box=(0.794,0.280,0.040,0.012)
iban  'GB94BARC10201530093459'   confidence=0.95  box=(0.236,0.358,0.213,0.008)
id    'INV-2026-0611'            confidence=0.95  box=(0.188,0.134,0.127,0.012)
  • Bounding boxes are drawn over the document in the interface, so a reviewer sees where each value was read from.
  • Confidences drive a 0.80 threshold. Below it, no comparison is made and the field goes to a person with the reading and the signed value side by side.
  • That decision is recorded in the audit trail with who made it and what they were shown.
  • The verdict function never sees a confidence score. It sees pass, fail, or needs a person.

We draw their bounding boxes into our own review screen rather than embedding the DWS Viewer, and the reason is the shape of the decision. A reviewer here is not reading a document, they are settling one field: the reading sits beside the value the signature covers, and the answer is confirm or correct. That is a row in a seven-check verdict, not a document session. The Viewer is the natural upgrade the moment a reviewer needs to see the whole page in context, and nothing in the pipeline would have to change, because the review step already takes the field, the box and the page as its input.

SerpApi

SerpApi answers the question our own records cannot, which domain the open web says a brand publishes, and that is the only way to catch somebody impersonating a company that never enrolled with us.

Every other check reads our own registry. Without live search, Signet has nothing to say about the overwhelming majority of companies, which have never heard of us. You can run this yourself in the live demo, using the instruction above: take the lookalike sample, type Maersk as the brand, and counterparty becomes the check that decides.

The open web publishes maersk.com for Maersk,
and this was signed by north-post.dev.

We call the google engine and read two structured fields out of the response, and the difference between them is load bearing:

  • knowledge_graph.website is an entity record: a statement about who a company is. Firm enough to refuse an enrolment.
  • organic_results[].link is a page that ranked for the name. Recorded, shown to the person signing, and never used to accuse anyone.

That distinction exists because we got it wrong first. Searching our fictional demo brand returned an unrelated northpost.org in ordinary results, and treating it as an entity record refused a legitimate signer the domain they actually controlled. Structured data is only useful if you respect which field you read it from.

Live search improves the result in a way a trained model cannot, because the answer has to be current. A company that moved its domain last month, or a lookalike registered this morning, is exactly where a model trained months ago is confidently wrong.

Not built, and worth naming because the design points at it: a company changing its billing domain is a news event, so google_news over the brand name would turn a domain change from something we discover on the next invoice into something we know about before it arrives.

name.com

name.com is where the trust anchor physically lives, because the public key that makes any of this verifiable is a DNS TXT record we create through their API on the issuer's own domain.

Four endpoints across three surfaces, each doing work the product depends on:

  • POST /domains:search powers the lookalike sweep, generating and searching name permutations of an enrolled brand.
  • POST /domains:checkAvailability tells an unregistered permutation from one somebody has taken, the difference between a hypothetical and a live threat.
  • POST /domains/{domain}/records publishes _signet.<domain> TXT, the key every verification reads.
  • DELETE /domains/{domain}/records/{id} handles revocation and key rotation.

The DNS write is not a convenience. It is the only irreversible act in the system, and the whole trust model rests on only the domain holder being able to perform it. That is the property DKIM and SPF have relied on for twenty years, which is why a key in DNS is a defensible anchor rather than a novel one.

Foxit

The agent reaches Foxit's PDF Services through their MCP server for the reversible work, and calls the eSign API directly, outside that catalogue, for the one step a person must perform.

We measured their premise rather than assuming it. Connecting to their MCP server and listing tools returns 32 tools, none containing the word "sign".

We agree with the boundary, and we would draw it more strictly. Foxit invites disagreement, so here is our position.

A catalogue is only a suggestion. Our agent also cannot publish a key to DNS, and we do not enforce that by leaving a tool out. The tool is present and always refuses:

Publishing the key for this domain is not something you can do. It is irreversible: the domain would vouch for every document signed with that key, to everyone, indefinitely. Only the broker publishes, and only against a countersigned authorisation.

We name it and refuse it rather than omitting it, because an agent that wants to publish and finds no tool invents another route. Naming it ends the attempt with an explanation it can report back.

The stronger claim: a completed envelope proves a person acted, not what they acted on. A recipient can be shown one thing and told another, and the webhook reporting completion comes from the same system that showed it to them. So we do not release a key on a status field:

  1. The authorisation carries a hash over the domain, the brand and the public key.
  2. After signing, we download the executed document and read it back as text through Foxit's MCP server.
  3. We look for that exact string. If what came back is not what we sent, it is absent and no key is published.

The check depends on the bytes of the thing the person signed.

Doctavian

Doctavian generates both documents that carry real logic, the invoice and the enrolment authorisation, from templates that loop, sum and branch on the data rather than swapping fields into a form letter.

The messy input is a forwarded email thread. assets/enrolment-request.txt has quoted replies, three different domains in one paragraph, the person who can sign buried on a cc line, and a legal disclaimer at the bottom. Three domains appear and only one of them signs.

The agent reads the domain, the brand and the signer out of that, and must quote the line each came from. Every quote is checked against the source text rather than believed, so a value the agent cannot point at never reaches a document somebody is asked to sign.

Doctavian then does what a mail merge cannot:

  • Loops. <mdoc:repeater> over the readings, printing each field beside the line it came from, however many there are.
  • Calculates. The invoice total is never sent to the template. It is derived with sum(Invoice.LineItems, "LineAmount").
  • Branches. <mdoc:paragraph hidden=…> on an expression over that same collection, so the ambiguity warning appears only when the text supported a second answer, and the large-payment clause only above the threshold. Both decided by the template, not the caller.

The result is a document that shows its working. A person about to authorise their domain to vouch for every future invoice sees each field beside the exact line it was read from, and a plain statement where a second reading was possible and rejected. That is what makes asking for the signature worth anything.

We took it all the way to signed with Doctavian too. Signing is a port with two implementations, DoctavianSignatures and FoxitSignatures, selected by one environment variable. We ran both end to end: generate, upload, envelope, send, a person signs, we read the executed document back, the key publishes to DNS. Doctavian places its signature field by anchor string, Foxit by text tag, and the same template carries both markers printed white on white, so one document works through either gateway.

Xano

Xano is the backend of record, holding the issuer registry, the duplicate ledger, the evidence cache and the audit trail, with the uniqueness guarantee that makes duplicate detection correct living in the database rather than in our code.

Seven endpoints in one API group over five Signet tables, and four of them carry conditional logic in the function stack rather than being CRUD: the duplicate ledger decides first sighting against a unique index, and the evidence cache enforces its own expiry on read so a stale answer reads as absent rather than as an answer. The five questions the challenge asks:

  • What software did you replace? Invoice fraud detection tools and the vendor verification modules bolted onto accounts payable suites, the ones that score a document on fonts and layout and tell you it looks fine. Underneath them sits the control those tools never actually replaced: a phone call to the supplier.
  • Why did you choose it? It is a control everyone agrees is inadequate and nobody has replaced, because the replacement is not a better inspection, it is a different question.
  • Which AI tools did you use? Claude Code for the codebase, and Xano's own CLI and developer MCP for the backend, which let the schema and endpoints be written as XanoScript, reviewed as a diff, and pushed from the command line.
  • How long did it take? Fourteen days. First commit 20 August, submitted 3 September, 112 commits.
  • What would have taken significantly longer? The backend itself: five tables, seven endpoints and a shared auth function, written and pushed in an afternoon. And the vendor work generally, where at least two services behaved differently from their published documentation.

Challenges we ran into

The document printed the answer to the question being asked about it. Our doctored sample came back CERTIFIED, which should have been impossible. The invoice printed the signed fields as body text, so the page contained the string iban=GB29NWBK…, and the extractor read the account number off that instead of the PAY TO block. The page agreed with itself while the payment block said something else. We removed the printed payload and kept the QR code.

A confidence score is not a measure of legibility. Extraction returned an amount of 15.s80.00 from a photograph of a genuine invoice and scored it 0.95. A letter inside a number is not a 95 percent reading of anything, and trusting that score flagged an authentic document, the exact failure this product exists to avoid. We now check the shape of a value as well as its score.

Live search was confident about a company that does not exist. Searching our fictional demo brand returned an unrelated northpost.org, which our matcher accepted, refusing a legitimate signer the domain they actually controlled. The matcher was far looser than intended: it also accepted n.com as plausibly belonging to Northpost Freight Services. We tightened matching to word boundaries and separated an entity record from a page that merely ranked for the name. Only the first can refuse somebody.

Three defects in Foxit's MCP server. The published console script name did not match the installed module, there was no __main__ guard, and the entry point called asyncio.run(mcp.run()), which current FastMCP rejects. We launch their server under its own interpreter and call mcp.run() directly.

Xano taught us a concurrency bug. Our first duplicate check read the row then inserted it, which two verifiers arriving at the same instant both pass. The unique index on the fingerprint column is what actually stops the second insert, so the guarantee belongs in the database. Refusing to answer beats answering "new" twice.

What we learned

  • Preconditions in code beat instructions in a prompt, and we measured it. Given these same tool schemas, one capable model skipped the diligence lookup when told to hurry, and another ran it, was handed the contradiction, and enrolled the lookalike anyway while reporting the check as passed. Transcripts in ADR 0007.
  • Which direction to fail matters more than how often. Refusing an enrolment costs somebody a retry. Falsely accusing a genuine invoice costs them a supplier relationship. Enrolment fails closed on weak evidence; verification never accuses on a page it could not read.
  • Using your own product finds what tests do not. Every bug above was found by running the thing, not by the 480 tests passing.

Honest limitations

  • Nothing at all, until the sender publishes a key. An invoice from a company that never enrolled cannot be certified. It comes back unsigned, which is honest rather than useful. Adoption is the constraint, not the cryptography. How that constraint breaks is the section below.
  • A certified verdict does not mean the invoice is owed. It means the named domain signed these fields and they have not changed. A real company can certify a real invoice for something you never ordered.
  • Whoever controls a domain can sign as that company. A hijacked domain signs too.
  • A second sighting flags, and today that is too blunt. Presenting the same document twice is how duplicate payment fraud works, so the ledger records every document ever checked and duplicate is one of the five checks that can fail a verdict. It is also what happens when accounts payable checks an invoice and approvals check the same one an hour later. Our ledger records that a document was checked, and what actually matters is whether it was presented for payment, which are not the same event. In a real deployment the ledger is scoped per payer and a second look inside one finance team is context on the screen rather than a failure. We did not build that, and a colleague re-checking your invoice will see it flagged.
  • We are not the first to anchor keys in DNS. DKIM has done it for email since 2007. Our argument is not that the mechanism is new, but that nobody has applied it to the payment-critical fields of a document that travels as paper and photographs, and that is where the money moves.

Numbers, and how to reproduce them

make test        # 480 tests pass, 82.28% branch coverage, no credentials, no network
git log --oneline | wc -l                    # 112 commits, 20 Aug to 3 Sept
find src -name '*.py' | xargs cat | wc -l    # 7,948 lines of source
find tests -name '*.py' | xargs cat | wc -l  # 5,589 lines of tests
dig +short TXT _signet.northpost.dev         # the key the whole system rests on
  • All seven checks running: GET /api/health returns notRunning: []
  • 32 Foxit MCP tools, none of them signing: tool_names() in src/signet/adapters/foxit_mcp.py
  • A verification end to end: 10.6 seconds against the live deployment

The test suite is blocked from opening a socket, so "runs offline" is enforced rather than asserted.

The one claim here you cannot rerun is the signed enrolment through both gateways, because completing an envelope needs a person to put their name to it and neither Foxit nor Doctavian will let a script do that. That is the point of the design rather than a gap in the evidence. Both runs happened on 3 September 2026, each ending with the key resolving at _signet.north-post.dev, and the video shows one of them. Everything else on this page is a command.

Who adopts this, and why they would

The obvious objection: the party who must act is the supplier publishing a key, and the party who loses the money is the buyer paying the wrong account. Nobody spends effort to protect somebody else.

That asymmetry is exactly why this is enforceable, because the buyer already holds the whip hand. Onboarding a supplier already means producing a W-9, an insurance certificate and bank verification before a first payment clears. Key publication is one more line on a list the buyer already dictates, and it is the cheapest line on it: one DNS record, once.

The DKIM comparison we make above closes the argument rather than opening it. DKIM did not win by persuading senders one at a time. Gmail and Yahoo made it a condition of delivery for bulk senders in 2024, and the remaining tail moved in a quarter. The equivalent here is not a million suppliers. It is one large accounts payable platform, or one bank at payment initiation, making an unsigned invoice over a threshold something a human has to approve by exception.

So the wedge is the buyer, not the supplier. The first customer is a mid-market company paying a few thousand invoices a month, where a single interception costs more than a year of the product. The pricing follows the loss rather than the seat count, and the supplier side stays free forever, because the supplier is distribution rather than revenue.

What's next

Enrolment is the bottleneck, so the next work makes it cost a supplier nothing: a one-click flow from the invoice their customer already received, and key publication through registrars beyond name.com so an issuer never leaves the tools they have. After that, the receiving end, where the check belongs inside the accounts payable inbox rather than on a page somebody has to remember to visit.

Built With

  • cryptography
  • dns
  • doctavian
  • ed25519
  • foxit-esign
  • foxit-pdf-services
  • gpt-oss-20b
  • httpx
  • javascript
  • model-context-protocol
  • name.com
  • nutrient-dws
  • nvidia-nim
  • python
  • rdap
  • react
  • render
  • serpapi
  • starlette
  • uvicorn
  • vercel
  • vite
  • xano
Share this project:

Updates