Guide · CSV import

Import past customers and revenue

How to export a product's past customers and revenue into the two CSV files the Ravenkeep console imports (Settings > Projects > your project > Import). It is written so that a developer or an AI agent can write the export script from this page alone.

The documentation is in English only. Ravenkeep em português

2 files
contacts.csv first, then revenue.csv
Idempotent
Customers by userId, payments by kind and reference
No events
No flows, notifications or active users from an import

What gets imported

Two files. contacts.csv has one row per customer: signup date, email, name, account and first-touch attribution. revenue.csv has one row per payment or refund. Import the contacts first: every revenue row needs its customer.

  • Imported data appears at its true dates: signups per day, customers, paying customers, LTV, revenue over time and the Attribution report.
  • Past behavioral events are not imported, and an import records none: no $signup, $revenue or $identify events, no active-user days (DAU, WAU and MAU do not move), no campaign flow enrollment, no goal conversions and no operator notifications.
  • Importing the same files again changes nothing. Customers are matched by userId and payments by kind plus reference, also against the revenue your backend already sends live, so nothing is counted twice.
  • Only owners and admins of the organization can import. The importer lists every import with who ran it, when, the file and its counts.

File format

RuleValue
EncodingUTF-8. A byte order mark (BOM) is accepted and ignored. A file that is not valid UTF-8 is refused.
HeaderThe first line names the columns. Names match ignoring case, spaces, _, - and ., so signed_up_at, SignedUpAt and signedUpAt are the same column. Order does not matter. Unknown columns are listed and ignored; a missing required column refuses the file.
DelimiterComma (recommended). A file delimited by semicolons or tabs is detected from its header line.
QuotingRFC 4180: wrap a value in double quotes when it contains the delimiter, a double quote or a line break, and double every quote inside it ("Studio ""Norte"""). Use your language's CSV library instead of joining strings.
Line endingsLF or CRLF. Blank lines are skipped, and so are lines of only spaces and delimiters (a spreadsheet's empty row, ,,,).
Empty cellNo value. Spaces around a value are trimmed.
Optional columnsOnly the required columns must be in the header. Leave an optional column out, or leave its cells empty.
LengthsLimits count characters as JavaScript does (string.length, UTF-16 code units), after trimming. A value over its limit rejects the row (too_long), except in the attribution columns, which are cut to the limit.
SizeAt least one row; at most 250,000 rows and 64 MiB per file. Skip a file that would hold only its header. Split a bigger export into several files and import them one after the other: each file is independent.
Row numbersErrors name the line where the row starts; the header is line 1.

A row with fewer values than the header, or with extra values that are not empty, is rejected as malformed_row (usually an unquoted comma or line break). Extra empty values, such as a trailing comma, are ignored.

contacts.csv

One row per customer. Required columns: userId and signedUpAt.

ColumnRequiredFormatNotes
userIdyesText, 1 to 255 characters, no control charactersYour product's stable user id: exactly the userId your backend sends to @ravenkeep/node. Once per file.
signedUpAtyesDate-time with a zone, such as 2025-03-14T09:26:53ZWhen the customer created their account. Drives signups per day and the Attribution cohort.
emailnoAn address with one @ and no spaces, up to 320 charactersThe customer's address. Campaign flows may email it. A row whose address does not have that shape is rejected: leave the cell empty when an address is unusable.
namenoText, up to 200 charactersDisplay name.
accountIdnoText, up to 255 charactersThe customer's company, workspace or organization id in your product (the SDK's account.id).
accountNamenoText, up to 200 charactersThe account's display name. Needs accountId.
utmSourcenoText, up to 255 characters (longer values are cut)utm_source of the first visit, such as google.
utmMediumnoText, up to 255 characters (longer values are cut)utm_medium, such as cpc.
utmCampaignnoText, up to 255 characters (longer values are cut)utm_campaign.
utmTermnoText, up to 255 characters (longer values are cut)utm_term.
utmContentnoText, up to 255 characters (longer values are cut)utm_content.
gclidnoText, up to 512 characters (longer values are cut)Google Ads click id.
fbclidnoText, up to 512 characters (longer values are cut)Meta click id.
msclkidnoText, up to 512 characters (longer values are cut)Microsoft Ads click id.
referrernoText, up to 2048 characters (longer values are cut)The referring page of the first visit: an absolute http(s) URL, else dropped.
landingPagenoText, up to 2048 characters (longer values are cut)The first page opened: an absolute http(s) URL or a path starting with /, else dropped.
attributionCapturedAtnoDate-time with a zoneWhen the first touch happened. Defaults to signedUpAt; ignored when every attribution column is empty.

Fill the attribution columns only with what you stored when the customer signed up (the first touch: the UTM parameters, click ids, referrer and landing page of their first visit). Values longer than the limit are cut. Leave every attribution column empty when you never stored it; never guess.

revenue.csv

One row per payment or refund that actually moved money. Required columns: userId, amount, currency, reference and occurredAt.

ColumnRequiredFormatNotes
userIdyesText, 1 to 255 charactersThe paying customer: a userId of contacts.csv or one Ravenkeep already knows from the SDK.
amountyesInteger in minor units (cents), 0 to 10000000000001999 is 19.99. Digits only: no decimal point, sign, symbol or separator. Always positive, refunds too.
currencyyesISO 4217 code, such as USDMust be the project's currency.
kindnopayment or refundDefaults to payment. A refund subtracts its amount from the customer's LTV.
referenceyesText, 1 to 255 characters, no control charactersThe stable id of the payment in your billing system (Stripe in_..., ch_... or re_...). With kind, the idempotency key.
occurredAtyesDate-time with a zoneWhen the money moved (the invoice's paid time, the refund's creation time).
  • Export only payments that succeeded: paid invoices, captured charges. Skip drafts, open, void and uncollectible invoices and failed charges.
  • Invoices of zero can be left out: a customer becomes paying only with a payment above zero.
  • One row per refund, with the refunded amount (positive) and kind refund. A partial refund is a refund of the partial amount.
  • Revenue belongs to a person: map an organization's invoices to the user who pays for it (its owner or billing contact), and include that user in contacts.csv.
  • For a payment your backend already sent live with revenue(), use the same reference, amount and customer it sent. A different amount or customer is reported as reference_conflict, the live record is kept and nothing is counted twice.

Dates and times

signedUpAt, occurredAt and attributionCapturedAt are instants with a time zone. Write them in UTC with a Z.

ValueAccepted
2025-03-14T09:26:53ZYes (recommended).
2025-03-14T09:26:53.123ZYes. Fractions are kept to the millisecond.
2025-03-14T06:26:53-03:00Yes, stored as 2025-03-14T09:26:53Z.
2025-03-14 09:26:53.123456+00Yes (the PostgreSQL text form of timestamptz).
2025-03-14No: no time and no zone.
2025-03-14 09:26:53No: no zone. If the source column holds UTC (a timestamp without time zone or a MySQL DATETIME written in UTC), append Z.
1741944413No: convert epoch seconds (Stripe timestamps) to ISO 8601 first.

Dates must fall between the years 1900 and 9999 and be at most 5 minutes after the import's clock. Daily charts group by UTC day.

Amounts and currency

amount is an integer in the currency's minor unit (cents), the unit Stripe and revenue() of @ravenkeep/node use. Digits only: no decimal point, sign, currency symbol or thousands separator. Nothing is rounded: a value with decimals is rejected.

Price`currency``amount`
19.99 US dollarsUSD1999
R$ 49,90BRL4990
500 yen (no minor unit)JPY500
1.250 Kuwaiti dinars (three decimals)KWD1250

currency must be the project's currency (shown in the importer). A row in another currency is rejected with currency_mismatch: Ravenkeep does not convert. Set the project currency before the first revenue import, because it cannot change once the project has revenue.

A payment adds its amount to the customer's lifetime value (LTV) and a refund subtracts it. A customer becomes paying at their earliest payment above zero.

Idempotency and merge rules

Import as often as you like: an import never creates a second customer and never counts a payment twice. Run the same files again after fixing rejected rows; finished rows come back as unchanged.

RecordKeyRule
CustomeruserIdOne customer per project and userId. A userId may appear once per contacts.csv; later rows with it are rejected as duplicate_user_id.
Payment or refundkind + referenceAlready recorded with the same customer, amount and currency (by an earlier import, or live by revenue()): unchanged. With another customer, amount or currency: rejected as reference_conflict, and the stored record is kept. occurredAt is not compared.

When the customer already exists

A customer your live integration already sent, or an earlier import created, is completed, never overwritten:

FieldRule
signedUpAtThe earliest of the stored and the imported date wins.
emailWritten only when the customer has no email. Imported addresses are marked as imported; campaign flows may email them, and a later call from your backend replaces them. An address that unsubscribed in the project (in any letter case, on any customer) stays unsubscribed: campaign flows skip it.
nameWritten only when empty.
accountId, accountNameWritten only when the customer has no account. When the stored account has the same id and no name, the name is filled.
Attribution columnsWritten only when the customer has no attribution yet. The first touch is never replaced.
First and last seenA new customer is first and last seen at its signup date. For an existing one, first seen moves back to the signup date when it was later and last seen never moves: an import is not activity.
Lifetime valueChanged only by new rows of revenue.csv.

An imported customer counts as signed up: a later signup() from your backend for the same userId does not enroll them in signup flows again. Live events after the import trigger flows as usual.

Mapping a typical SaaS database

Read from a replica or with a read-only user: the export never writes to the source. The usual sources:

ColumnTypical source
userIdusers.id, the same id your backend sends to the SDK.
email, nameusers.email, users.name (or first and last name joined with a space).
signedUpAtusers.created_at.
accountId, accountNameThe user's organization, workspace or team (organizations.id, organizations.name). When a user belongs to several, pick one deterministically: the one the user last used, else the earliest membership, ties broken by id.
Attribution columnsThe UTM columns stored at signup, if any. Otherwise leave them empty.
revenue userIdThe customer your live integration credits for that payment: usually the organization's owner (earliest owner membership) or billing contact. If the payer changed after a payment was sent live, the import reports it as reference_conflict and keeps the live record.
amountThe amount your backend sends to revenue() for that payment (Stripe invoice total or amount_paid, whichever it uses); charge amount_captured; refund amount.
currencyThe invoice, charge or refund currency, uppercased.
referenceStripe invoice id in_... (charge id ch_... without invoices); refund id re_....
occurredAtInvoice status_transitions.paid_at, charge created, refund created (epoch seconds: convert to ISO 8601 UTC).
  • Leave out internal, staff and test accounts, and payments of other products that share the billing account (filter them by their metadata).
  • Keep inactive users that were real customers: their history is part of your signups and revenue. If your product deletes users for good, their history is gone: export what remains (their organization's payments go to its current payer).
  • Leaving someone out of the export does not remove them from Ravenkeep: customers your live integration already sent stay.
  • Sort the output (contacts by signedUpAt then userId, revenue by occurredAt then reference) so two runs write the same files.
contacts.sqlSQL
-- psql: \copy (<this query on one line>) TO 'contacts.csv' WITH (FORMAT csv, HEADER)
SELECT u.id AS "userId",
       u.email AS "email",
       u.name AS "name",
       to_char(u.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') AS "signedUpAt",
       o.id AS "accountId",
       o.name AS "accountName"
FROM users u
LEFT JOIN LATERAL (
  SELECT org.id, org.name
  FROM memberships m JOIN organizations org ON org.id = m.organization_id
  WHERE m.user_id = u.id
  -- the organization the user last used, else the earliest membership
  ORDER BY (m.organization_id = u.last_organization_id) DESC, m.created_at, org.id
  LIMIT 1
) o ON true
ORDER BY u.created_at, u.id;
revenue.sqlSQL
SELECT payer.user_id AS "userId",
       i.total AS "amount", -- the amount your live revenue() call sends
       upper(i.currency) AS "currency",
       'payment' AS "kind",
       i.stripe_invoice_id AS "reference",
       to_char(i.paid_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') AS "occurredAt"
FROM invoices i
JOIN LATERAL (
  -- the organization's earliest owner pays
  SELECT m.user_id FROM memberships m
  WHERE m.organization_id = i.organization_id AND m.role = 'owner'
  ORDER BY m.created_at, m.user_id
  LIMIT 1
) payer ON true
WHERE i.status = 'paid' AND i.total > 0
ORDER BY i.paid_at, i.stripe_invoice_id;

Example files

contacts.csvCSV
userId,email,name,signedUpAt,accountId,accountName,utmSource,utmMedium,utmCampaign
u_1001,ana@example.com,Ana Souza,2024-11-02T14:05:31Z,org_77,Acme Ltda,google,cpc,brand
u_1002,bruno@example.com,"Bruno, Jr.",2025-01-15T09:00:00Z,org_77,Acme Ltda,,,
u_1003,,Carla Dias,2025-02-20T22:47:10.250Z,org_81,"Studio ""Norte""",newsletter,email,feb-launch
revenue.csvCSV
userId,amount,currency,kind,reference,occurredAt
u_1001,1999,USD,payment,in_1QaB2cD3eF4gH5iJ,2024-11-02T14:07:02Z
u_1001,1999,USD,payment,in_1QkL6mN7oP8qR9sT,2024-12-02T14:07:11Z
u_1003,4990,USD,payment,in_1QuV0wX1yZ2aB3cD,2025-02-20T22:50:00Z
u_1003,4990,USD,refund,re_3QeF4gH5iJ6kL7mN,2025-02-27T10:12:45Z

Row errors

The dry run and the import report every rejected row with its line, code, column and message, and the console downloads them as a CSV to fix. The other rows of the file are imported.

CodeMeaningFix
requiredA required value is empty.Fill the column for that row, or leave the row out.
invalid_formatA value has the wrong shape: a date without a zone, an amount with decimals, an invalid email.Write the value in the format of the column table.
too_longA value is longer than the column allows.Shorten it.
out_of_rangeA number or date is outside the accepted range (a future date, an amount above the cap, a negative amount).Check the source value; refunds are positive amounts with kind refund.
invalid_valuekind is not payment or refund.Use one of the two values.
malformed_rowThe row has fewer values than the header, or extra non-empty values.Quote values that contain the delimiter or a line break.
duplicate_user_idThe userId already appeared earlier in contacts.csv; the first valid row wins.Export one row per customer.
currency_mismatchThe currency is not the project's currency.Import only revenue in the project currency; Ravenkeep does not convert.
unknown_contactNo customer with this userId exists in the project.Add the customer to contacts.csv and import it first, or fix the payer id.
reference_conflictThis kind and reference is already recorded with another customer, amount or currency.Check which record is right; references must identify one payment.

Running the import

  • Open the importer: Settings > Projects > your project > Import (owners and admins).
  • Add the files: contacts.csv and, optionally, revenue.csv. The console checks the columns and every row in the browser.
  • Dry run: counts the rows that would be created, updated, unchanged or rejected. Nothing is written.
  • Import: rows go in batches of up to 1,000 rows (and about 1 MiB), contacts before revenue. Keep the tab open. If it stops (a closed tab, a network failure), import the same file again: finished rows come back unchanged.
  • Fix rejected rows: download them, correct them and import that file.

Checklist for an export script

  • Read only: never write to the source database or billing account.
  • Write two UTF-8 files with a header row and the column names of this page, comma delimited, quoted by a CSV library.
  • userId and reference are the values your live integration sends.
  • Date-times in UTC with Z; amounts as integers in minor units; currency uppercase and equal to the project currency.
  • One row per userId in contacts.csv; one row per payment or refund in revenue.csv; every revenue userId present in contacts.csv (or already known to Ravenkeep).
  • Leave out internal and test accounts; leave attribution columns empty unless you stored them.
  • Sort the rows so the output is deterministic.
  • The files hold personal data: write them outside version control and delete them after the import.
  • Print what was written and what was skipped (and why), so a person can compare it with the dry run.