A guy sent me a screen recording last month. No message with it. Just the video.
It's his onboarding. Five steps, looks great, he clearly spent time on it. Then step six yanks the user out to our app. Our colours, our domain, our everything.
And you can see the poor user pause. Like, hold on, who am I signing with now?
He never said a word about it. Just the video. Somehow that's worse than a complaint.
Fine. We built an API. And an embed. Here's the whole thing.
The API just calls the same stuff the UI calls
This is the only decision I actually thought about.
export async function requireApiCaller(request: Request): Promise<ApiCaller> {
// ...check the bearer key, hand back a `SessionUser`
}
See the return type? SessionUser. That's it, that's the whole idea.
Because createDocumentFromTemplate takes one. getTemplate takes one. The quota check takes one. They all already take one. So the API doesn't get to invent its own rules about who can touch what. It borrows the rules that were already sitting there.
Two copies of "is this person allowed" is two chances to screw it up. And they always drift. You look away for a week and they're different.
Endpoints, nothing exciting:
GET /v1/templates
POST /v1/templates/:id/send
GET /v1/documents
GET /v1/documents/:id
GET /v1/documents/:id/file
The list uses a cursor instead of ?page=2. Had to. It's sorted by updated_at and that thing moves constantly, every time somebody opens a document or signs or comments. So rows keep jumping around. Page through that with offsets and you'll skip stuff, silently, and never know. Grabbing one extra row tells you if there's more without a count query.
Errors look the same everywhere:
{
"error": {
"code": "quota_exceeded",
"message": "You have sent 10 of 10 documents this month.",
"quota": { "used": 10, "limit": 10, "resets_at": "2026-10-01T00:00:00.000Z" }
}
}
Check code, not the message. The message is for you at 2am with your face in the logs (or me, let's be honest).
You know what actually ate the most time? Making sure you never get HTML back. React Router throws Responses all over. A 404 here, a 429 from the rate limiter there, and a redirect that shoves the browser at the marketing page. All perfectly fine when a human is looking at it. All completely useless to your res.json(). So everything goes through one wrapper that cleans it up, and that redirect becomes a 401, because your script does not have a session to go fix, does it.
The embed, and the thing that nearly bit me
Send with embed: true, we skip the emails entirely, you get a URL per person:
{
"embed": {
"expires_in": 1800,
"sessions": [
{ "recipient_id": "rcp_b4c1...", "url": "https://putmysign.com/embed/sign/GfT9…" }
]
}
}
Shove it in an iframe yourself if you want. Or use the script, which is honestly just an iframe and a message listener, that's it:
<div id="sign" style="height: 800px"></div>
<script src="https://putmysign.com/embed.js"></script>
<script>
Putmysign.mount("#sign", {
url: session.url,
onSigned: (e) => finish(e.documentId),
onDeclined: (e) => bail(e.documentId),
});
</script>
mount hands you back a destroy(). Please call it. Swap that container out in a SPA without it and the old listener is still hanging around, so next time every single handler fires twice and you sit there going "why did it save twice". I found this out the way everybody finds this out.
Ok now the good part.
Our normal signing page, the one from the email, cannot be put in a frame by anyone. frame-ancestors 'none'. Nobody. The only reason the embed one can be framed is that a customer told us, with a real authenticated key, which of their own domains to trust.
So the URL you get isn't the actual signing token. It's a little sealed box around it:
interface EmbedPayload {
t: string; // the real signing token
k: string; // which API key made this
exp: number; // epoch ms
}
That k is doing all the work. Opening the box is how the route finds out which origins are allowed to frame the page. So the CSP gets built fresh on every request off the key, not parked in some static header.
And if your key has no origins set? You get 'none'. It renders nowhere. Blank box.
Felt kind of mean when I wrote it. Some dev wires it all up, gets nothing, curses my name.
But the other way is defaulting to open, and defaulting to open means somebody's half finished Friday afternoon setup is now a signing page that literally any website can wrap in a frame and aim at their own users. Nah. Blank box it is.
If a default is going to fail, make it fail loud. The quiet ones become bugs nobody ever reports.
The 30 minute expiry is the same paranoia from the other direction. An emailed link has to survive a week because people open those things next Tuesday. But that's the worst possible property for something you hand to somebody else's webpage, where it just sits in their DOM and their logs and their analytics the entire time it's valid. So the expiry lives inside the sealed box. Can't stretch it by messing with something next to it. No table, no cleanup job, nothing to forget about.
Webhooks, because the iframe only sees its own little world
onSigned means that one person signed. Cool. Three other people might still owe you a signature.
For "ok it's actually done now", you want these:
document.sent · recipient.viewed · recipient.signed
recipient.declined · recipient.commented · document.completed
Signed t=<timestamp>,v1=<hmac>, five tries over about a day, at least once delivery. At least once, meaning yes, the same event will show up twice sometimes. Check the id, throw away the repeat.
There's a signature checker right on the docs page that runs the actual function our dispatcher signs with. Paste in a real delivery and you'll know in ten seconds if it's your code or mine.
It's raw body vs parsed body. I'm telling you now. It's always raw body vs parsed body.
That's it really. Docs at putmysign.com/docs/api, keys and webhooks live under Developers, and test keys do the full run without emailing anybody or charging you.
The bit I'm still chewing on is that origins default. Have you shipped an embed thing like this? Did you make people set up the allowlist before anything works, or let them start wide open and nag them later? And did anyone actually ever go back and fix it?













