What portward.ai stores
The control plane at signup.portward.ai runs signup, license keys and the image registry, and keeps what it needs for customers who sign up in one SQLite database. It holds nothing from your data sources. Customers we give access by hand, without signup, are not in it. Their relay access is an entry in AWS SSM Parameter Store holding the customer’s name, the SHA-256 hash of their relay token and any of their own domains, and the relay server copies these entries every minute to check tokens. Their registry access is an IAM user tagged with their name and holding a pull-only key, or their AWS account id in the registry’s access policy. This page lists every table and column in that database, which columns hold personal data, and how long each is kept. A test compares this page with the database the code creates, so a column added in the code fails the test suite until it is listed here.
Where it is
Section titled “Where it is”The database is on one server in the United States, in AWS region us-east-2, on an encrypted disk. Once a day, and before each deploy, a copy goes to an encrypted S3 bucket in the same AWS account, and each copy is deleted after 35 days. If a copy fails to upload to S3, it stays on the server’s disk and nothing deletes it. If the server is replaced, its old encrypted disk stays in the same AWS account. Nothing deletes it today. Amazon SES reports bounces, complaints and rejected sends, with the address concerned, through a queue that keeps a report for up to 14 days until the server reads and deletes it.
Personal data
Section titled “Personal data”- Email addresses of people who signed up, who asked for a signup, new-key or account sign-in link, whose mail bounced for good (a hard bounce) or who marked our mail as spam, and the address on a Stripe invoice that matches no license.
- Company names as people typed them at signup.
- IP addresses in the audit log, as the full IPv4 address or the /64 network for IPv6.
- The reason a customer types when they extend their trial.
For a soft bounce, such as a full mailbox, we keep only the message id SES gave the email, not the address. The database holds no passwords and no payment card numbers. License keys and link tokens are stored only as SHA-256 hashes.
How long it is kept
Section titled “How long it is kept”Today nothing deletes accounts, licenses, audit rows or the list of addresses we no longer email. One-time links are deleted. A link works for 30 minutes, and each new link deletes the links that expired more than a day before, whether a person asked for it or a payment created it. A retention rule was approved on 2026-10-25: account and license rows for the account’s life plus 2 years, audit log IP addresses cut to /24 after 13 months, the bounce and complaint list kept, and backups 35 days. It is not built yet, so this page says what the code does today.
Table accounts
Section titled “Table accounts”One row per company that holds a license.
| Column | What it holds | Personal data | Kept |
|---|---|---|---|
id | A UUID, random for a free trial; for a paid signup it is made from the hash of its signup link’s token, so every try of one signup gets the same id | No | No end date |
email | The work email address that signed up | Yes | No end date |
company_key | What makes two signups the same company, the email’s domain (the whole address only if webmail signups are allowed, which they are not by default) | Only when it is a whole address | No end date |
company | The company name as typed at signup | Yes, when the company is one person (a sole trader) | No end date |
created_at | When the account was created | No | No end date |
Table licenses
Section titled “Table licenses”One row per license, linked to its account.
| Column | What it holds | Personal data | Kept |
|---|---|---|---|
name | The license’s name, usually taken from the email’s domain, which is also its relay address <name>.gw.portward.ai | No | No end date |
account_id | The account it belongs to | No | No end date |
key_sha256 | The SHA-256 hash of the license key (the key is shown once and not stored), or a random value that no key matches until a paid license’s first key is collected | No | No end date |
plan | trial or paid | No | No end date |
seats | How many seats the license covers | No | No end date |
created_at | When the license was issued | No | No end date |
trial_days | The free trial’s length in days, 0 for a paid license, whose trial Stripe keeps | No | No end date |
started_at | The first install (first image pull or relay connection) | No | No end date |
expires_at | When the license ends | No | No end date |
extended_at | When the one self-serve trial extension was granted | No | No end date |
extension_reason | The reason the customer typed for the extension, up to 200 characters | It can, it is free text | No end date |
revoked_at | When Portward revoked the license | No | No end date |
revoked_reason | Why Portward revoked it, typed by Portward, up to 200 characters | No | No end date |
stripe_customer_id | Stripe’s id for the paying customer | No | No end date |
stripe_subscription_id | Stripe’s id for the subscription | No | No end date |
billing_status | Stripe’s subscription status, such as active or past_due | No | No end date |
admin_seats | Admin seats bought | No | No end date |
user_seats | User seats bought | No | No end date |
current_period_end | When the current billing period ends, by Stripe’s clock | No | No end date |
stripe_read_at | When the last Stripe read applied to this license started | No | No end date |
billing_interval | month or year | No | No end date |
Table links
Section titled “Table links”One row per one-time link sent by email: to confirm a signup, to get a new key, to get the key after paying, or to sign in to the account page.
| Column | What it holds | Personal data | Kept |
|---|---|---|---|
token_sha256 | The SHA-256 hash of the link’s token (the token itself is only in the email) | No | Deleted by the first new link made more than a day after it expires. There is no fixed upper limit. |
purpose | signup, reissue, license_ready or account_signin | No | Deleted by the first new link made more than a day after it expires. There is no fixed upper limit. |
email | The address the link was sent to | Yes | Deleted by the first new link made more than a day after it expires. There is no fixed upper limit. |
company | The company name typed at signup, or the license’s company otherwise | Yes, when the company is one person (a sole trader) | Deleted by the first new link made more than a day after it expires. There is no fixed upper limit. |
name | The email’s domain for a signup, the license’s name otherwise | No | Deleted by the first new link made more than a day after it expires. There is no fixed upper limit. |
created_at | When the link was sent | No | Deleted by the first new link made more than a day after it expires. There is no fixed upper limit. |
expires_at | 30 minutes after it was sent | No | Deleted by the first new link made more than a day after it expires. There is no fixed upper limit. |
used_at | When the link was used (each link works once) | No | Deleted by the first new link made more than a day after it expires. There is no fixed upper limit. |
Table suppressed
Section titled “Table suppressed”Addresses we never email again, after Amazon SES reports a hard bounce or a complaint.
| Column | What it holds | Personal data | Kept |
|---|---|---|---|
email | The address, in lower case | Yes | No end date |
reason | The kind of bounce or complaint SES reported | No | No end date |
at | When the address was added | No | No end date |
Table audit
Section titled “Table audit”A log of signup steps, account page sign-ins, key changes, license and subscription changes, invoices, payment disputes, refunds and fraud warnings, emails about a trial’s end, image pulls and mail problems.
An image pull is logged at most once an hour for each license and image, counted since the control plane last started.
Each row’s hash covers the row and the hash of the row before it.
Running docker exec portward-control python -c "from portward.control.cli import main; raise SystemExit(main(['audit']))" on the server prints audit log BROKEN when a row was changed or deleted, unless the hashes of every later row were rebuilt too.
The command exits with 0 either way, so a script that runs it has to read what it prints.
It does not say which row.
It cannot find rows removed from the end of the log.
The hashes use no secret key, so someone who can write to the database can rebuild them.
| Column | What it holds | Personal data | Kept |
|---|---|---|---|
seq | The row number | No | No end date |
at | When it happened | No | No end date |
actor | The email address of the person who acted or of the license’s account, the address on a Stripe invoice that matches no license, or admin, admin (host), ses or stripe for Portward, Amazon SES and Stripe events that match no license | Yes, when it is an address | No end date |
action | What happened, such as signup_requested, license_issued or image_pulled | No | No end date |
subject | What it happened to, usually a license name or an email domain. It is the address itself when an address is added to the suppressed list, the email’s subject line when an email to a suppressed address is skipped, the SES message id for a soft bounce or a rejected send, and the Stripe customer id or Stripe charge id for a Stripe event that matches no license | Yes, when it is an address | No end date |
ip | The IP address of the request, the full IPv4 address or the /64 network for IPv6, Stripe’s own address when a Stripe checkout issues a license, restarts a subscription or is refused, and -, local, relay, stripe or timer for other events no person started | Yes | No end date |
detail | Context, such as the company name on a signup request, the image pulled, or the reason given for a trial extension | It can, it holds typed text | No end date |
prev_hash | The hash of the row before | No | No end date |
hash | This row’s hash, over its content and prev_hash | No | No end date |
The action column holds one of these values: account_signed_in, account_signin_requested, charge_disputed, charge_refunded, checkout_refused, checkout_started, early_fraud_warning, image_pulled, invoice_finalization_failed, invoice_paid, invoice_payment_failed, key_reissued, key_rotated, key_shown, license_issued, license_lapsed_sent, license_resubscribed, license_revoked, mail_bounced_soft, mail_rejected, mail_suppressed, mail_suppressed_added, never_installed_canceled, never_installed_canceled_late, never_installed_skipped, reissue_requested, resubscribe_started, seat_change_applied, seat_change_expired, signup_refused, signup_requested, trial_cancel_mark_cleared, trial_cancel_undone, trial_ending_sent, trial_ending_skipped, trial_extended, trial_lapsed_sent, trial_started.
SQLite also keeps its own sqlite_sequence table, which holds only the last audit row number.
Table stripe_events
Section titled “Table stripe_events”Stripe events already handled, so a repeated delivery is applied only once.
| Column | What it holds | Personal data | Kept |
|---|---|---|---|
id | Stripe’s event id | No | Deleted by the first Stripe event that arrives 90 days or more later. There is no fixed upper limit. |
at | When it was handled | No | Deleted by the first Stripe event that arrives 90 days or more later. There is no fixed upper limit. |
Table notices
Section titled “Table notices”Emails about a license’s term or a plan change that came due, so each goes out at most once. A row is written just before its email is sent. It stays when the email is not sent because the address is on the suppressed list, because a lapsed license was already told it was never installed, or because sending an email that a Stripe event started failed. A free-trial email that fails to send is tried again.
| Column | What it holds | Personal data | Kept |
|---|---|---|---|
name | The license | No | No end date, unless cleared so the email can be sent again |
kind | trial_ending, never_installed, lapsed or change_expired | No | No end date, unless cleared so the email can be sent again |
ref | What the email was about, the trial end it announced or that passed, the subscription that lapsed, or for a plan change that was not paid in time the Stripe invoice, or the Stripe event when there is no invoice | No | No end date, unless cleared so the email can be sent again |
at | When it was recorded just before sending | No | No end date, unless cleared so the email can be sent again |
Table account_sessions
Section titled “Table account_sessions”Sign-in sessions for the account page. A session ends 30 minutes after its last request, or 8 hours after sign-in.
| Column | What it holds | Personal data | Kept |
|---|---|---|---|
id_hash | The SHA-256 hash of the session cookie’s value | No | Deleted at sign-out, when someone signs in again in the same browser, on the first request after the license is revoked, or by the next sign-in from any account after the session ends. There is no fixed upper limit. |
name | The license the session is signed into | No | Deleted at sign-out, when someone signs in again in the same browser, on the first request after the license is revoked, or by the next sign-in from any account after the session ends. There is no fixed upper limit. |
csrf | A random token that the account page’s forms send back | No | Deleted at sign-out, when someone signs in again in the same browser, on the first request after the license is revoked, or by the next sign-in from any account after the session ends. There is no fixed upper limit. |
created_at | When the person signed in | No | Deleted at sign-out, when someone signs in again in the same browser, on the first request after the license is revoked, or by the next sign-in from any account after the session ends. There is no fixed upper limit. |
expires_at | 8 hours after sign-in | No | Deleted at sign-out, when someone signs in again in the same browser, on the first request after the license is revoked, or by the next sign-in from any account after the session ends. There is no fixed upper limit. |
seen_at | The last request | No | Deleted at sign-out, when someone signs in again in the same browser, on the first request after the license is revoked, or by the next sign-in from any account after the session ends. There is no fixed upper limit. |