Bring your existing customers
Customers who bought before you used Sealcord keep what they paid for: you issue each of them a Sealcord licence for the order they already have, send them the new key, and ship an app version that accepts it. Sealcord cannot read keys made by another system, so their old keys are not imported; each customer gets a new key signed with your product’s key.
Before you start
Section titled “Before you start”- Your plan. Each licence you issue counts as an active licence from the moment it is issued.
Count the customers you will move and check your plan has room, or upgrade first in the Console
(plans). A licence that does not fit answers
402 plan_limit_reachedand nothing is written. - An admin API key with
licences:readandlicences:write, kept on the machine that runs the move (the key your backend uses). - A list of orders from your old system: an order id, the buyer’s email address, the number of seats or machines, and an end date for anything sold for a term.
Issue one licence per old order
Section titled “Issue one licence per old order”Run the move as a script that you can stop and start again. For each order:
-
Store a licence id with the order (a new random UUID), so a second run sends the same
idand issues nothing twice. -
Send an external reference that names the old order, such as
import:order:1234: 1 to 200 printable ASCII characters, never an email or a name. It lets you find the licence by its old order later. -
Issue the licence with the customer’s email, the limit they bought, and
expires_atfor a term that has an end:Terminal window curl https://api.sealcord.com/v1/admin/licences \-H "authorization: Bearer $SEALCORD_TOKEN" \-H 'content-type: application/json' \-d '{"id": "2f9a6c3e-8b14-4d7a-b0e5-3c6d9e1f4a72","product_id": "acme-notes","email": "ada@example.com","limit_kind": "seats","limit_count": 2,"external_ref": "import:order:1234"}'// Node.js 18 or later, as an ES module (a .mjs file) for the top-level await.const token = process.env.SEALCORD_TOKEN;if (!token) throw new Error('SEALCORD_TOKEN is not set');const response = await fetch('https://api.sealcord.com/v1/admin/licences', {method: 'POST',headers: {authorization: `Bearer ${token}`,'content-type': 'application/json',},body: JSON.stringify({id: '2f9a6c3e-8b14-4d7a-b0e5-3c6d9e1f4a72',product_id: 'acme-notes',email: 'ada@example.com',limit_kind: 'seats',limit_count: 2,external_ref: 'import:order:1234',}),});const json = await response.json();if (!response.ok) {console.error(`HTTP ${response.status}`, json);process.exit(1);}console.log(json);// Cargo.toml: reqwest = { version = "0.13", features = ["blocking", "json"] }// serde_json = "1"use reqwest::blocking::Client;use serde_json::{json, Value};fn main() -> Result<(), Box<dyn std::error::Error>> {let token = std::env::var("SEALCORD_TOKEN")?;let response = Client::new().post("https://api.sealcord.com/v1/admin/licences").bearer_auth(token).json(&json!({"id": "2f9a6c3e-8b14-4d7a-b0e5-3c6d9e1f4a72","product_id": "acme-notes","email": "ada@example.com","limit_kind": "seats","limit_count": 2,"external_ref": "import:order:1234",})).send()?;let status = response.status();let body: Value = response.json()?;if !status.is_success() {return Err(format!("HTTP {status}: {body}").into());}println!("{body:#}");Ok(())}// Go 1.18 or later: go run main.gopackage mainimport ("bytes""encoding/json""fmt""log""net/http""os")func main() {token := os.Getenv("SEALCORD_TOKEN")if token == "" {log.Fatal("SEALCORD_TOKEN is not set")}url := "https://api.sealcord.com/v1/admin/licences"body, err := json.Marshal(map[string]any{"id": "2f9a6c3e-8b14-4d7a-b0e5-3c6d9e1f4a72","product_id": "acme-notes","email": "ada@example.com","limit_kind": "seats","limit_count": 2,"external_ref": "import:order:1234",})if err != nil {log.Fatal(err)}req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))if err != nil {log.Fatal(err)}req.Header.Set("Authorization", "Bearer "+token)req.Header.Set("Content-Type", "application/json")res, err := http.DefaultClient.Do(req)if err != nil {log.Fatal(err)}defer res.Body.Close()var answer anyif err := json.NewDecoder(res.Body).Decode(&answer); err != nil {log.Fatal(err)}out, _ := json.MarshalIndent(answer, "", " ")if res.StatusCode/100 != 2 {log.Fatalf("HTTP %d: %s", res.StatusCode, out)}fmt.Println(string(out))}// .NET 8 or later: the Program.cs of a console app (dotnet new console).using System.Net.Http.Headers;using System.Net.Http.Json;var token = Environment.GetEnvironmentVariable("SEALCORD_TOKEN")?? throw new InvalidOperationException("SEALCORD_TOKEN is not set");using var http = new HttpClient();http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);var response = await http.PostAsJsonAsync("https://api.sealcord.com/v1/admin/licences", new{id = "2f9a6c3e-8b14-4d7a-b0e5-3c6d9e1f4a72",product_id = "acme-notes",email = "ada@example.com",limit_kind = "seats",limit_count = 2,external_ref = "import:order:1234",});var json = await response.Content.ReadAsStringAsync();if (!response.IsSuccessStatusCode){Console.Error.WriteLine($"HTTP {(int)response.StatusCode}: {json}");Environment.Exit(1);}Console.WriteLine(json);// Java 11 or later: java Main.javaimport java.net.URI;import java.net.http.HttpClient;import java.net.http.HttpRequest;import java.net.http.HttpResponse;public class Main {public static void main(String[] args) throws Exception {String token = System.getenv("SEALCORD_TOKEN");if (token == null) {throw new IllegalStateException("SEALCORD_TOKEN is not set");}String body = "{"+ "\"id\": \"2f9a6c3e-8b14-4d7a-b0e5-3c6d9e1f4a72\", "+ "\"product_id\": \"acme-notes\", "+ "\"email\": \"ada@example.com\", "+ "\"limit_kind\": \"seats\", "+ "\"limit_count\": 2, "+ "\"external_ref\": \"import:order:1234\""+ "}";HttpRequest request = HttpRequest.newBuilder().uri(URI.create("https://api.sealcord.com/v1/admin/licences")).header("authorization", "Bearer " + token).header("content-type", "application/json").POST(HttpRequest.BodyPublishers.ofString(body)).build();HttpResponse<String> response =HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString());if (response.statusCode() / 100 != 2) {System.err.println("HTTP " + response.statusCode() + ": " + response.body());System.exit(1);}System.out.println(response.body());}}201 Createdanswers the key. A repeat answers409 conflict; read the licence by its id to get the key the first run issued, as the order handler does. -
Pace the calls. Admin routes allow 300 requests a minute per API key; on
429 rate_limited, wait the seconds inRetry-After-adminand send the same request again.
Skip orders that were refunded or have ended: there is nothing to move. Every licence you issue is in its audit log, so you can see what the move did.
Send customers their new key
Section titled “Send customers their new key”Sealcord does not email keys to your customers: you do, from the address they know you by. Tell them why the key changed, that they paid nothing more, and where to enter it. Keep the email short and put the key on a line of its own, so it is copied whole.
Ship an app that accepts both keys
Section titled “Ship an app that accepts both keys”For a while, your app needs to accept the old keys and Sealcord keys:
- Ship a version that checks a key the new way first (offline, then activation), and the old way only for keys that do not start with your product’s prefix.
- When most customers have the new key, show the others a message that asks them to enter it.
- Retire the old check in a later version.
New sales issue Sealcord licences from the start, from your backend or a payment integration.
Questions
Section titled “Questions”Can I keep the customers’ old keys?
Section titled “Can I keep the customers’ old keys?”No. A Sealcord key is signed with your product’s Ed25519 key and carries its own fields, so a key another system made cannot pass Sealcord’s checks. The customer keeps their purchase; only the key changes.
Will the new licences use my customers’ seats?
Section titled “Will the new licences use my customers’ seats?”Not until their app activates. A licence takes a seat on a machine only when the app calls
POST /v1/activations with its key.
What about subscriptions that are still running?
Section titled “What about subscriptions that are still running?”Issue the licence without an end date, and expire it when the subscription ends, from the backend that hears about the cancellation (on a cancellation). New subscriptions sold through a payment integration are handled for you.