Email, payments and sign-in
Transactional email, payments and sign-in for an app, without accounts of your own.
An app usually needs three things beyond hosting: sending email, taking payments and signing its users in. Each is one call on the project, and none needs an account with another provider first:
app.emailsends transactional email from the app's own address, sign-in codes, and broadcasts to people who asked for them.app.paymentsopens a payments account the owner claims, so money goes straight to them.app.sign_incommits sign-in code into the app's repo, with the users kept in the app's own database.
Turn on email
import noclick
app = noclick.Project.from_name("booking")
setup = app.email.enable(domain="acmebooking.com", from_address="bookings@acmebooking.com", from_name="Acme Booking")
print(setup.status, setup.from_address) # pending bookings@acmebooking.com, then pending_dns, then activeWithout domain, the app sends from its default address, like noreply@booking.noclick.site, so it can send before you have a domain. With one, mail goes from from_address on it (noreply@ the domain unless you pass one). Calling enable() again changes only what you pass.
The first enable() returns pending while NoClick sets up the app's sending, usually within a minute. Mail sent meanwhile waits and goes out once it's set up. If setting it up fails, pending_reason says so and NoClick keeps trying.
A domain you bought through NoClick gets its DNS records written for it and turns active within minutes. Any other domain is connected for you and waits in pending_dns until the records are in place:
app = noclick.Project.from_name("booking")
setup = app.email.enable(domain="acme.test")
for record in setup.records:
print(record.type, record.name, record.value, record.status, record.purpose)The records arrive within seconds of the call. Add them where the domain's DNS is hosted; NoClick looks for them every minute at first, then every ten, and status() says where they stand:
app = noclick.Project.from_name("booking")
print(app.email.status().status) # pending_dns, then activeThe records cover DKIM (so receiving servers trust the mail) and a bounce subdomain with its own MX and SPF (which is how the app learns a message bounced), for the domain and for its news. subdomain, which broadcasts go from. A DMARC policy is listed too, as recommended rather than required, since the domain may have one already. Records not in place within 72 hours stop being checked; call enable(domain=...) again to start over (the records may change).
A subdomain of a domain your account holds can send any app's mail, a user's included, so each user's app mails from an address of yours (mail.ana.acmebooking.com under the account's acmebooking.com). Only a key acting for the whole account may choose one; its records are written for you under a bought domain, and listed in records under a connected one.
Send a message
app = noclick.Project.from_name("booking")
sent = app.email.send(
to="ana@example.com",
subject="Your booking is confirmed",
text="See you Friday at 3pm.",
html="<p>See you <b>Friday at 3pm</b>.</p>",
idempotency_key="booking-1042-confirmation",
)
print(sent.id, sent.status) # sentGive it a text body, an html one or both. to is one address, or a list of up to 10 that each receive the message on their own; reply_to sets where replies go. An idempotency_key makes the send safe to retry: the same key sends once for the app, whichever key or deployment sends it, so a confirmation never goes out twice.
Send from the app's own code the same way, through noclick.Project.current().email, which signs in as the app without a key (see Reach them from the app's code), or through the HTTP API.
Send a sign-in code
A one-time code goes through send_code, which writes the message itself, in the app's name:
app = noclick.Project.current()
app.email.send_code("ana@example.com", "482913", expires_in_s=300)Codes go on a lane of their own, with a daily limit apart from the app's other mail, so a new app's sign-ins never wait on its receipts. One address gets at most 5 codes in 15 minutes (noclick.RateLimited past that), and each code is charged as one message. The subject names what the code is for (purpose: sign_in or verify_email), never the code. Sign-in sends its codes this way.
See what was delivered
app = noclick.Project.from_name("booking")
for message in app.email.messages(to="ana@example.com", limit=10):
print(message.at, message.subject, message.status, message.opened)messages() lists what the app sent, newest first. A message moves from queued (NoClick has it) to sent (on its way) to delivered (the recipient's server took it), or ends bounced, complained (marked as spam) or failed, with the reason in error. Sent to several recipients, a message shows the worst any of them met, else how far the slowest got.
Two of these are approximate. complained is best-effort: some mailbox providers, Gmail among them, report no single complaint to anyone, only how much of a sender's mail is marked as spam overall. opened is as far as can be told: privacy proxies open some mail on the reader's behalf, so it can read True for a message nobody read. It's None for a message with no HTML, or a sign-in code, which aren't tracked.
Email from a preview
An app's preview sends only to its own people: your account's address and those of the members of your workspaces. Every other recipient's copy is kept rather than sent. A kept copy isn't charged, doesn't count toward a daily limit, and is named in the message's captured; a message whose every copy was kept is captured, with the text and html it would have sent. Production sends as it always has.
app = noclick.Project.from_name("booking")
for kept in app.email.messages(environment="preview"):
print(kept.captured, kept.subject, kept.text) # ['ana@example.com'] Your sign-in code ...environment keeps the messages production ("production") or its previews ("preview") sent, and every message says which in environment. A sign-in code a preview kept reads back the same way, so you can sign in to a preview as any test user. A broadcast from a preview reaches your own people and counts the rest in its captured.
Send a newsletter
send is for transactional mail. A newsletter or an announcement to many people goes through broadcast, which sends one message to up to 50,000 addresses:
app = noclick.Project.from_name("booking")
app.email.enable(postal_address="Acme Booking, 1 Ferry Building, San Francisco, CA 94111")
subscribers = [row["email"] for row in app.database("DB").query("select email from subscribers")]
result = app.email.broadcast(
subscribers,
"What's new at Acme Booking",
html="<h1>Group bookings are here</h1><p>Book for up to 12 people at once.</p>",
idempotency_key="newsletter-2026-10",
).wait()
print(result.sent, result.skipped)Listing an address says its owner opted in to mail from the app. What NoClick takes care of:
- Its own stream and address. Broadcasts go out on a bulk stream apart from transactional mail, under their own
broadcast_daily_limit, frombroadcast_from_addresson the domain'snews.subdomain (bookings@news.acmebooking.com), so a campaign's complaints never pause a receipt, at NoClick or at the mailbox providers, which judge each domain by its own mail. A broadcast needs a domain of the app's own: from the default address it raisesnoclick.ActionRequired. - Unsubscribes. Every message carries its recipient's own
List-Unsubscribeheader, with one-click unsubscribe, and an unsubscribe link in its footer, both to a plain page with no NoClick branding. Anyone who unsubscribed from the app's broadcasts, bounced or marked one as spam is skipped, and named inskippedwith thereason. Unsubscribing stops broadcasts, never the app's receipts. - The footer. It carries the
postal_addressyou giveenable(), as the law asks of bulk mail;broadcastraisesnoclick.ActionRequireduntil there is one. - Retries.
idempotency_keyis required and names the broadcast: sending again with the same key sends nothing new, so a job that crashes halfway can simply run again.
broadcast returns an operation whose wait() gives the Broadcast; each message's delivery shows in messages() like any other. Mail goes out at the pace it can be sent, so a large broadcast takes minutes (at 100 messages a second, 50,000 take about eight). Canceling the operation stops what hasn't gone yet.
Someone who unsubscribed and later signs up again stays skipped until you lift their unsubscribe. Do it once they've confirmed, by clicking a link you emailed them, since mail after an unsubscribe is spam unless the person asked again:
app = noclick.Project.from_name("booking")
app.email.resubscribe("ana@example.com") # True if she was unsubscribedA bounce or a spam complaint isn't lifted. NoClick keeps the unsubscribes and your app keeps its own list of who signed up, so there's no list of unsubscribes to read back or keep in step: the send skips them, and a confirmed sign-up lifts one.
Email limits and billing
send is for transactional mail: receipts, confirmations, reminders. Sign-in codes go through send_code, and newsletters and other bulk mail through broadcast.
- A new app sends up to 200 messages a day, 1,000 sign-in codes and 1,000 broadcast recipients; each limit grows as its mail keeps landing (to twice its best delivered day of the last week, while its bounces and complaints stay low). Past one, the call raises
noclick.RateLimitedwithretry_after.setup.daily_limit,setup.sent_today,setup.broadcast_daily_limitandsetup.broadcast_sent_todaysay where you are. - If too many messages bounce or are marked as spam, sending pauses:
status(orbroadcast_status, for broadcasts) becomespaused,paused_reasonsays which (bounce_rate,complaint_rate,mailbox_provider_feedbackorblocklisted), and theproject.email.pausedevent fires. What was waiting to go out fails rather than going out days later. Clean up the list you send to, then contact support to resume. - Each recipient is charged as one message (the mail service's price for it), a broadcast's and a code's included, and shows as App email on the Usage page. A message skipped before sending (an address that bounced) isn't charged.
- Mail is sent from the US, wherever the app runs.
Take payments
app = noclick.Project.from_name("booking")
setup = app.payments.enable(country="US")
print(setup.mode) # test
print(setup.secrets) # ['PAYMENTS_SECRET_KEY', 'PAYMENTS_PUBLISHABLE_KEY', 'PAYMENTS_WEBHOOK_SECRET']
print(setup.claim_url, setup.test_expires_at)enable() opens a payments account for the app, in test mode, and puts its keys in the app's secrets. Your app's code reads them and takes payments with the payment processor's own library, which setup.processor names, so code you generate picks the right one; in test mode only test cards work, so you can build the whole checkout before anyone owns the account. The processor is the same for every app, so app.payments.status().processor names it even before enable(): an agent writing the app can be told it up front and write the payment code in the same change that declares payments.
country is where the owner's business is, and the account can only ever be there, so it's required the first time (a country the processor doesn't serve raises noclick.NotAvailable, listing the ones it does). Pass owner_email for who the processor reminds before an unclaimed test account expires (your NoClick account's email otherwise), and business_name to prefill the claim (the app's name otherwise).
NoClick delivers the account's payment events to the app's /api/ (setup.webhook_url), signed with PAYMENTS_WEBHOOK_SECRET the way the processor signs its own, so its library's webhook check accepts them; handle the events you need there. They cover checkouts, payments and saved payment methods, customers, subscriptions and invoices, refunds and disputes, and products and prices. An event the app doesn't answer with a 2xx within 10 seconds is sent again, with backoff, for three days. Refer to prices by their lookup keys rather than their ids, since a price's id differs between test and live.
The account belongs to whoever claims it. Send the owner claim_url, a NoClick link that always leads to their next step: they sign in with the payment processor (or sign up), give their business details and bank account, and install NoClick's payments app on their account, which is how NoClick sets up the live keys; that step names NoClick. Money goes straight to them; NoClick takes no cut and never holds the funds.
Go live
app = noclick.Project.from_name("booking")
setup = app.payments.status()
print(setup.mode, setup.claim_step, setup.next_step)mode moves through these steps, and next_step says what to do in each:
mode | claim_step | What it means |
|---|---|---|
test | claim | The account takes test cards and waits to be claimed before test_expires_at. |
claimed | application | The owner gives the payment processor their business details. |
claimed | install | The owner installs NoClick's payments app on their live account. |
claimed | verification | The processor is verifying the business; nothing to do until it has. |
live | Real payments work: the live keys are in the app's production secrets. dashboard_url is the owner's own dashboard. | |
disconnected | The owner removed NoClick's payments app or its keys. Once they reinstall it from their dashboard, call enable() again. |
At live, NoClick puts restricted live keys in the app's production secrets, with what taking payments needs (checkout, payments, customers and their portal, products and prices, subscriptions and invoices, refunds), signs the live account's events with a live PAYMENTS_WEBHOOK_SECRET (it differs from the test one), and copies the test account's products and prices across, each price keeping its lookup key. The project.payments.updated event fires at each step. Deploy once the account is live, so production picks up the live keys:
app = noclick.Project.from_name("booking")
if app.payments.status().mode == "live":
app.deploy()Preview deployments keep the test keys, so a preview never takes a real payment.
An unclaimed test account and its test data are deleted at test_expires_at, 60 days after enable(), and the processor emails owner_email a week before. After that, enable() opens a fresh test account, with new keys and no data, and the app must deploy again. Refunds, disputes and payouts are handled in the owner's own dashboard.
Add sign-in
app = noclick.Project.from_name("booking")
setup = app.sign_in.enable(providers=["email", "google"]).wait()
print(setup.status, setup.commit, setup.tables)enable returns an operation. It writes its secrets, creates a users table and the four it needs (sessions, the accounts people sign in with, pending codes and rate limits) in the app's bound database, commits sign-in code for the app's framework into its repo, and deploys. Nothing reaches the repo until the tables and secrets are in place, so an operation that fails before its commit leaves the code as it was, and calling enable again after any failure picks up where it stopped. The code is yours: read it, change it, and it keeps working the same way when you export the app (its one call to NoClick sends the sign-in codes, a single function to point at another sender after export).
The code is written with an open-source library, which setup.library and setup.version name, so code you write against the app's sign-in uses the right calls. It works on nextjs, remix and astro apps (an astro app needs server output) and on a static site as it is, going by the app's framework or, when the app names none, by what its code builds as, which a deploy reads the same way. A nextjs app written in JavaScript gets TypeScript and the React and Node types in its devDependencies, since sign-in's files are TypeScript, and a tsconfig.json extending its jsconfig.json, so its import aliases keep working. On a static site, its pages stay untouched, and a worker in front of them (server/worker.ts, described by a wrangler.jsonc; Apps) answers /api/ and /sign-in. Its pages sign people in by including <script src="/auth.js" defer></script> and marking elements: data-auth="signed-in" and data-auth="signed-out" show only in that state, data-auth="name" shows who's signed in, and data-auth="sign-in" and data-auth="sign-out" are the links. A site that already runs its own worker keeps it (noclick.NotAvailable says to add sign-in to that worker), and a client-only vite app raises noclick.NotAvailable for now. A remix app whose app/routes.ts lists its routes needs sign-in's two routes in that list: until they're there, the operation fails with noclick.ActionRequired naming them, before anything is committed.
Providers are email (a one-time code sent through app.email.send_code, which is enabled with the default sender if it isn't yet), google, apple, microsoft (work, school and personal accounts) and github. Google, Apple and Microsoft sign people in through NoClick, with nothing to set up at the provider: setup.managed_providers lists them, and they work as soon as the operation ends. GitHub signs in with your own GitHub client (see below). Calling enable again with another list changes them. Pass users_table to name the table something else, and database to name the binding when the app binds more than one database.
To review the change before it's live, commit it on a branch. The branch gets a preview deployment of its own, which signs people in on its own address with email and the providers that sign in through NoClick, so you can try them there before production has sign-in:
app = noclick.Project.from_name("booking")
op = app.sign_in.enable(providers=["email"], branch="add-sign-in")
print(op.wait().commit)NoClick never overwrites a file you changed. If a change would replace one, it goes on a branch of its own instead (setup.branch, with setup.edited_files naming the files), for you to review and merge; naming a branch whose files hold edits of yours raises noclick.Conflict, naming them.
NoClick writes its own secrets and never shows them: AUTH_SECRET (signs sessions) and AUTH_PROXY_SECRET (lets previews sign in with your own clients through production's callback URLs), both set once, AUTH_URL, the production URL, and, while any provider signs in through NoClick, the app's client of NoClick's sign-in (NOCLICK_SIGN_IN_CLIENT_ID and NOCLICK_SIGN_IN_CLIENT_SECRET).
Sign in through NoClick, or with your own client
A provider that signs in through NoClick asks the person to sign in to NoClick on its screen ("Choose an account to continue to NoClick"), then brings them back to the app. Each person is a subject of their own in each app (the same every time they sign in there with that provider), and their email is marked verified only where the provider vouches for it: Google's and Apple's, and a Microsoft account's when it's personal or its domain belongs to the organization. An existing user is joined to a sign-in by email only when both sides are verified.
Apple gives a person's name only the first time they sign in with Apple through NoClick, so the app keeps the name it got then. Someone who chooses Hide My Email signs in with an @privaterelay.appleid.com address (is_private_email in what the app's sign-in reads); Apple passes mail on to it only from sending domains registered with Apple.
To show the app's own name on Google's or Microsoft's screen, sign in with your own client at that provider: name it in own_credentials. GitHub always signs in this way, and Apple only through NoClick. Register the URL in callback_urls at the provider, then set the client's id and secret as secrets; missing_secrets lists the ones still to set:
app = noclick.Project.from_name("booking")
setup = app.sign_in.enable(providers=["email", "google"], own_credentials=["google"]).wait()
print(setup.callback_urls) # {'google': 'https://booking.noclick.site/api/auth/callback/google'}
print(setup.missing_secrets) # ['GOOGLE_CLIENT_ID', 'GOOGLE_CLIENT_SECRET']Until they're set, that provider's button says it isn't configured; the others work. The callback URLs are on the app's own NoClick address, so adding a domain doesn't change them, and previews sign in through them too, once production has sign-in. A provider on your own client stays there when you call enable again without own_credentials; pass own_credentials=[] to move every provider onto NoClick's.
Keep sign-in up to date
When NoClick ships a newer version of the library (a security fix, say), status().update_available names it and the project.sign_in.update_available event fires, with its severity (critical for a security fix, else recommended). NoClick never pushes it to production by itself; upgrade() does, with the same care for files you edited:
app = noclick.Project.from_name("booking")
if app.sign_in.status().update_available:
print(app.sign_in.upgrade(branch="sign-in-update").wait().commit)Errors
noclick.ActionRequired:email.sendwhile the domain's records are still pending or sending is paused,email.broadcastfrom the default address or before the app has apostal_address,sign_in.enableon an app with no bound database, or aremixapp whose route list lacks sign-in's routes (when its operation runs).next_stepsays what to do.noclick.NotAvailable:sign_in.enableon a framework NoClick can't add sign-in to,payments.enablein a country the processor doesn't serve, or a service that isn't enabled on this deployment yet.noclick.Conflict:payments.enablewith anothercountrythan the account's,sign_in.enablewhile a change is running or on abranchwhose files hold edits of yours.noclick.RateLimited: over one of the app's daily email limits, or too many codes to one address.noclick.InvalidRequest: an address that isn't one, more than 10 recipients, a provider that doesn't exist, or nocountrythe first time.
The Python reference documents ProjectEmail, ProjectPayments and ProjectSignIn in full.