Skip to content

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.

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.

  • 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.

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.

One row per company that holds a license.

ColumnWhat it holdsPersonal dataKept
idA 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 idNoNo end date
emailThe work email address that signed upYesNo end date
company_keyWhat 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 addressNo end date
companyThe company name as typed at signupYes, when the company is one person (a sole trader)No end date
created_atWhen the account was createdNoNo end date

One row per license, linked to its account.

ColumnWhat it holdsPersonal dataKept
nameThe license’s name, usually taken from the email’s domain, which is also its relay address <name>.gw.portward.aiNoNo end date
account_idThe account it belongs toNoNo end date
key_sha256The 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 collectedNoNo end date
plantrial or paidNoNo end date
seatsHow many seats the license coversNoNo end date
created_atWhen the license was issuedNoNo end date
trial_daysThe free trial’s length in days, 0 for a paid license, whose trial Stripe keepsNoNo end date
started_atThe first install (first image pull or relay connection)NoNo end date
expires_atWhen the license endsNoNo end date
extended_atWhen the one self-serve trial extension was grantedNoNo end date
extension_reasonThe reason the customer typed for the extension, up to 200 charactersIt can, it is free textNo end date
revoked_atWhen Portward revoked the licenseNoNo end date
revoked_reasonWhy Portward revoked it, typed by Portward, up to 200 charactersNoNo end date
stripe_customer_idStripe’s id for the paying customerNoNo end date
stripe_subscription_idStripe’s id for the subscriptionNoNo end date
billing_statusStripe’s subscription status, such as active or past_dueNoNo end date
admin_seatsAdmin seats boughtNoNo end date
user_seatsUser seats boughtNoNo end date
current_period_endWhen the current billing period ends, by Stripe’s clockNoNo end date
stripe_read_atWhen the last Stripe read applied to this license startedNoNo end date
billing_intervalmonth or yearNoNo end date

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.

ColumnWhat it holdsPersonal dataKept
token_sha256The SHA-256 hash of the link’s token (the token itself is only in the email)NoDeleted by the first new link made more than a day after it expires. There is no fixed upper limit.
purposesignup, reissue, license_ready or account_signinNoDeleted by the first new link made more than a day after it expires. There is no fixed upper limit.
emailThe address the link was sent toYesDeleted by the first new link made more than a day after it expires. There is no fixed upper limit.
companyThe company name typed at signup, or the license’s company otherwiseYes, 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.
nameThe email’s domain for a signup, the license’s name otherwiseNoDeleted by the first new link made more than a day after it expires. There is no fixed upper limit.
created_atWhen the link was sentNoDeleted by the first new link made more than a day after it expires. There is no fixed upper limit.
expires_at30 minutes after it was sentNoDeleted by the first new link made more than a day after it expires. There is no fixed upper limit.
used_atWhen the link was used (each link works once)NoDeleted by the first new link made more than a day after it expires. There is no fixed upper limit.

Addresses we never email again, after Amazon SES reports a hard bounce or a complaint.

ColumnWhat it holdsPersonal dataKept
emailThe address, in lower caseYesNo end date
reasonThe kind of bounce or complaint SES reportedNoNo end date
atWhen the address was addedNoNo end date

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.

ColumnWhat it holdsPersonal dataKept
seqThe row numberNoNo end date
atWhen it happenedNoNo end date
actorThe 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 licenseYes, when it is an addressNo end date
actionWhat happened, such as signup_requested, license_issued or image_pulledNoNo end date
subjectWhat 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 licenseYes, when it is an addressNo end date
ipThe 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 startedYesNo end date
detailContext, such as the company name on a signup request, the image pulled, or the reason given for a trial extensionIt can, it holds typed textNo end date
prev_hashThe hash of the row beforeNoNo end date
hashThis row’s hash, over its content and prev_hashNoNo 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.

Stripe events already handled, so a repeated delivery is applied only once.

ColumnWhat it holdsPersonal dataKept
idStripe’s event idNoDeleted by the first Stripe event that arrives 90 days or more later. There is no fixed upper limit.
atWhen it was handledNoDeleted by the first Stripe event that arrives 90 days or more later. There is no fixed upper limit.

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.

ColumnWhat it holdsPersonal dataKept
nameThe licenseNoNo end date, unless cleared so the email can be sent again
kindtrial_ending, never_installed, lapsed or change_expiredNoNo end date, unless cleared so the email can be sent again
refWhat 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 invoiceNoNo end date, unless cleared so the email can be sent again
atWhen it was recorded just before sendingNoNo end date, unless cleared so the email can be sent again

Sign-in sessions for the account page. A session ends 30 minutes after its last request, or 8 hours after sign-in.

ColumnWhat it holdsPersonal dataKept
id_hashThe SHA-256 hash of the session cookie’s valueNoDeleted 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.
nameThe license the session is signed intoNoDeleted 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.
csrfA random token that the account page’s forms send backNoDeleted 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_atWhen the person signed inNoDeleted 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_at8 hours after sign-inNoDeleted 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_atThe last requestNoDeleted 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.