Scripts

Give another application an address, take whatever it posts, and write it into your tables with a few lines of Python or TypeScript.

On this page

The idea #

Another application says something happened here — a payment went through, an order shipped, somebody filled in a form — and this one writes it down. A shop, a payment processor and a CMS can keep each other current without any of them polling the others.

What arrives is a JSON body somebody else designed, and what has to come out is a row in your table. Something has to do that translation, and it is deliberately not configuration: no field mapping screen, no expression language invented for the occasion. It is code — a function called main(event) that takes the body and writes what it means with db["shop"].sql().

That is a deliberate reversal. Integration tools grew mapping screens and flow builders because code was the hard part and not everybody could write it. It is not the hard part any more: a short handler is quick to write, quicker to read, and an AI assistant will draft one from a sentence. So the screens are the cost now, not the help. This page is kept as small as it can be, and its purpose is to get you to the editor.

Built-in functions #

Everything below is already in scope in a handler; nothing needs importing. Anything else is ordinary Python or TypeScript — the standard library, and the packages installed in the project's packages box.

PythonTypeScriptWhat it does
db["shop"].sql(statement, params=None, limit=None) await db.source("shop").sql(statement, params?, limit?) Runs one statement — SELECT, INSERT, UPDATE, DELETE — on the data source called shop, in that database's own SQL. Values go in params as ?. Answers a list of rows as dicts; on a write, .rowcount (.rowCount) is how many rows changed. limit caps the rows a read returns (200 at most). In a Test a write is listed, not run.
db["shop"].get(table, **where) await db.source("shop").get(table, where) The first row whose columns equal the values given — get("orders", code="A-1") — or None (null). Needs at least one column to match on.
db["shop"].all(table, order_by=None, limit=None) await db.source("shop").all(table, {orderBy, limit}) Every row of a table, ordered by one column if given, up to the cap.
with db["shop"].transaction(): await db.source("shop").transaction(async (tx) => { … }) Statements inside commit together when the block ends, or are all rolled back if it raises. One source each, one at a time; one still open when main returns is rolled back.
secrets["NAME"] secrets.NAME The project's secrets (Project settings → Secrets), by name. Read-only; values never appear in the log.
email(to, subject, body="", html=None, attachments=None) email(to, subject, body?, {html, attachments}) Records an email, sent after main returns and only on a real run — a Test sends nothing. An attachment is {"name": "sales.csv", "content": text or bytes, "type": optional}. Twenty emails and 5 MB of attachments per run. Goes out from the project's Google account when one is connected, from this server's address otherwise.
download(name, content, type=None) download(name, content, {type}) Offers a file to whoever pressed Run or Test: it appears with the result with a Download button, and the browser saves it. content is text or bytes; the type is guessed from the name when left out. 5 MB per run. A run from an address or a clock has nobody to give it to and drops it.
http(url, method=None, json=None, data=None, headers=None, timeout=20, sign=None) await http(url, {method, json, data, headers, timeout, sign}) One request, made now, to a host in the project's allowed hosts. json= sends JSON, data= text or bytes as is; the method is POST with a body and GET without. Answers a response with status, ok, headers (lower-case names), text, json() and raise_for_status() (raiseForStatus()). A bad status is an answer, not an error. sign="SECRET" signs the body with that secret, the way an address checks it.
to_csv(rows, columns=None) toCsv(rows, columns?) Rows as CSV text, header first. Columns are the keys in the order they first appear, or the ones named. None is empty, booleans are true/false, lists and dicts are JSON.
to_table(rows, limit=20, columns=None) toTable(rows, {limit, columns}) Rows as a plain-text table, for an email body or a print: the first limit rows and a line saying how many more there were.
print(...) console.log(...) Shown with the run as its output, and kept with the event in the log. It is the debugger.
QueryError QueryError Raised by anything on db: the database refused the statement, the source has no such name, a statement was not allowed. The traceback points at your line.
HttpError HttpError Raised by http() when there was no answer at all — no route, unknown host, a timeout, or a host the project has not allowed.

In TypeScript every call to db and http() returns a promise, so it is awaited; the rest are plain functions. Python also has create_engine("10lines://shop") once SQLAlchemy is in the packages box — see Reading and writing.

What sets a script off #

Every script is the same shape — def main(event) in Python, export default async function main(event) in TypeScript — and what differs is what starts it. A script carries a list of triggers, and event["event"] says which one it was:

Triggerevent["event"]event["payload"]
I press Run manual empty — the code reads what it needs with db["shop"].sql()
Something posts to an address the name the sender gave it the body that was posted
A clock schedule empty — the code decides what to fetch

A script can have several. The same code that tidies a table when you press Run can also run every night, and nothing about it has to be written twice.

Running on a clock #

Tick A clock and write a five-field cron expression. Times are UTC, said here rather than left to be found out: a project's rows can be in one timezone and its owner in another, and picking the server's would mean a schedule that moves when the server does.

ExpressionWhen
0 3 * * * every day at 03:00
*/15 * * * * every quarter hour
0 9 * * 1-5 weekday mornings at 09:00
0 0 1 * * the first of every month

        import requests

def main(event):
    # Nothing comes in: a scheduled run is the code's turn to go and look.
    orders = requests.get(
        "https://api.example.com/orders?since=yesterday&limit=40",
        headers={"Authorization": "Bearer " + secrets["SHOP_KEY"]},
    ).json()

    for order in orders:
        # PostgreSQL; needs a unique index on orders.code.
        db["shop"].sql(
            "INSERT INTO orders (code, total) VALUES (?, ?) "
            "ON CONFLICT (code) DO UPDATE SET total = excluded.total",
            [order["id"], order["total"]],
        )

      

A missed run is skipped, not caught up

A server that was off between midnight and nine comes back and runs an hourly script once, not nine times. "Every hour, sync the orders" means keep the orders current, and nine syncs in a row do the work of one while looking like a stampede from the far end. If every occurrence has to happen — an invoice a month — write the check into the handler, where you can see it.

Reading and writing the database #

A script reads and writes a data source with one call, db["shop"].sql(statement, params=None, limit=None), where shop is the data source's name as the menu lists it, under Data sources. The name is always said: db.sql() on its own is refused with the names the project has, so a script never depends on which source happens to be first. In TypeScript it is db.source("shop"). The call runs any SELECT, INSERT, UPDATE or DELETE in that database's own SQL, and limit caps the rows it returns. Values go in params as ? placeholders and never into the statement — what you are writing came out of somebody else's body, and a statement built by joining strings is one apostrophe away from meaning something else.

def main(event):
    order = event["payload"]

    # A read is a list of dicts.
    seen = db["shop"].sql("SELECT id, status FROM orders WHERE code = ?", [order["id"]])

    # A write is the same call; .rowcount says how many rows it changed.
    if seen:
        changed = db["shop"].sql("UPDATE orders SET total = ? WHERE id = ?",
                         [order["total"], seen[0]["id"]])
        print("updated", changed.rowcount)
    else:
        db["shop"].sql("INSERT INTO orders (code, total) VALUES (?, ?)",
               [order["id"], order["total"]])

    db["shop"].sql("DELETE FROM carts WHERE email = ?", [order["email"]])

A read returns a list of dicts. A write returns a list too, whose .rowcount is the number of rows it changed; it is empty unless the statement hands rows back. In TypeScript every call is a promise, and the count is .rowCount:

export default async function main(event: CmsEvent) {
  const order = event.payload;

  const changed = await db.source("shop").sql(
    "UPDATE orders SET status = ? WHERE code = ?",
    ["paid", order.id],
  );
  console.log("marked paid:", changed.rowCount);

  const paid = await db.source("shop").sql("SELECT code, total FROM orders WHERE status = ?", ["paid"]);
  return { paid: paid.length };
}

Two shorthands for the two commonest reads:

row  = db["shop"].get("orders", code="A-1")        # one row, or None
rows = db["shop"].all("orders", order_by="id")     # every row, up to the cap

In TypeScript, await db.source("shop").get("orders", {code: "A-1"}) and await db.source("shop").all("orders", {orderBy: "id"}).

A project can have up to five data sources, each with the same calls under its own name: db["crm"] is the one called crm (in TypeScript, db.source("crm")). Two sources are two servers, so a join across them happens in your code:

def main(event):
    order = db["shop"].get("orders", code=event["payload"]["code"])

    # db["crm"] is the data source called crm: the same calls, another server.
    db["crm"].sql("UPDATE contacts SET last_order = ? WHERE email = ?",
                  [order["code"], order["email"]])

Each statement commits as it runs. To make several commit together, or not at all, put them in a transaction. It belongs to one data source: db["crm"].transaction() is another source's, and nothing commits two sources at once.

def main(event):
    order = event["payload"]

    # Both statements commit when the block ends, or neither does: an exception
    # inside it rolls back everything the block wrote.
    with db["shop"].transaction():
        db["shop"].sql("UPDATE stock SET qty = qty - ? WHERE sku = ?",
               [order["qty"], order["sku"]])
        db["shop"].sql("INSERT INTO shipments (code, sku) VALUES (?, ?)",
               [order["id"], order["sku"]])

In TypeScript the transaction is a function that is awaited:

await db.source("shop").transaction(async () => {
  await db.source("shop").sql("UPDATE stock SET qty = qty - ? WHERE sku = ?", [order.qty, order.sku]);
  await db.source("shop").sql("INSERT INTO shipments (code, sku) VALUES (?, ?)", [order.id, order.sku]);
});

A transaction holds one of the data source's connections, and the locks of what it wrote, until it ends. One that is still open when main returns is rolled back, and the run's log says so. On Postgres a statement that fails inside a transaction ends it: committing afterwards is an error, and nothing in it is kept.

Rows, not tables

DROP, ALTER, CREATE and TRUNCATE are refused: a table's shape is changed on the schema page, which keeps its field settings, views and scripts with it. One statement per call — no ; between two.

A write is plain SQL

It goes straight to the database. Nothing casts the values or checks them against the column's field settings, nothing sets updated_at for you.

To learn a new row's id, ask for it in the statement where the database can say it: RETURNING on Postgres and MariaDB, OUTPUT INSERTED on SQL Server. MySQL has neither; read the row back by something you know, such as its code.

# Postgres and MariaDB
added = db["shop"].sql("INSERT INTO orders (code, total) VALUES (?, ?) RETURNING id",
               ["A-9", 120])
new_id = added[0]["id"]

# SQL Server
added = db["shop"].sql("INSERT INTO orders (code, total) OUTPUT INSERTED.id VALUES (?, ?)",
               ["A-9", 120])

If you would rather use SQLAlchemy, put it in the packages box and it works — the real package from PyPI, on a connection that goes back through the same path, for reads and writes. 10lines://crm is the data source called crm, with the dialect of the database it really is: Postgres, MySQL, MariaDB or SQL Server. The name is always said, as with db["crm"]. Reflection works, so Core queries need no model written by hand:

import sqlalchemy as sa

def main(event):
    engine = sa.create_engine("10lines://shop")

    # engine.begin() commits when the block ends, and rolls back if it raises.
    with engine.begin() as c:
        orders = sa.Table("orders", sa.MetaData(), autoload_with=c)
        late = c.execute(
            sa.select(orders.c.id, orders.c.code).where(orders.c.status == "new")
        ).mappings().all()

        c.execute(
            sa.update(orders)
            .where(orders.c.id.in_([row["id"] for row in late]))
            .values(status="late")
        )

    return [row["code"] for row in late]

The ORM works too. Transactions are real: engine.begin() and a session's commit() commit, and rollback() or an exception takes everything back. A connection opened with engine.connect() that is closed without commit() rolls back, as SQLAlchemy says it does. Use one transaction per data source at a time, and do not mix it with db["shop"].transaction() on the same source.

import sqlalchemy as sa
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, Session

class Base(DeclarativeBase):
    pass

class Order(Base):
    __tablename__ = "orders"
    id: Mapped[int] = mapped_column(primary_key=True)
    code: Mapped[str]
    total: Mapped[int]

def main(event):
    # 10lines://crm is the data source called crm, on its own database's dialect.
    engine = sa.create_engine("10lines://shop")

    with Session(engine) as session:
        order = Order(code=event["payload"]["id"], total=event["payload"]["total"])
        session.add(order)
        session.commit()
        return {"id": order.id}

The ORM in a Test

A Test records writes instead of running them, so an object the ORM adds gets no key back from the database, and the ORM stops there. Run runs it.

This needs a box that can ask

The database is asked while your code is running, so the box has to be able to ask a question and wait for the answer. The Zygo sandbox this server runs scripts in can. A hosted box somewhere else may not; there, db says so rather than hanging. The scripts page names which box this server uses.

1. The address #

Every script with an address trigger is given its own address. You do not choose it, and it is not guessable:

https://10lines.io/hooks/<project>/<token>

The token is the whole of the authentication. There is no API key to send and no header to set, for a plain reason: you are usually pasting this into a form in somebody else's product, and that form has one field in it. An address nobody can guess, given to one sender, is what fits there.

Treat the address as a password

Anyone who has it can post to it. Keep it out of screenshots, public repositories and client-side JavaScript. If one gets out, press New address on the script — the old one stops working at that moment, and you paste the new one into the sender.

Try it before wiring anything up. This works exactly as written once you have replaced the address with yours:

Sending an event by hand
curl -X POST https://10lines.io/hooks/7/whin_Kd3qP1xY.../order.paid \
  -H 'content-type: application/json' \
  -d '{"id": "A-1001", "customer": "Ada Lovelace", "total": 42.5}'
What comes back
HTTP/1.1 202 Accepted
content-type: application/json

{"ok": true, "status": "queued", "event": "order.paid", "id": 913}

202, immediately. The event is written down and the request is answered; the code runs a moment later. That is on purpose — a sender held open while an interpreter boots is a sender that times out and sends the same order twice. Whether the code worked is shown on the script's page, not in this response.

Checking who actually posted #

The address token says who may post here. A signature says who actually did, and the two are not the same promise: a token is pasted into somebody else's dashboard, travels in a URL, and ends up in their logs. A signing key does none of that.

An address can check a shared secret. Put the secret in Secrets, then choose it under the address. A request whose signature does not match is refused with a 401 and nothing runs — not queued, not logged as an event, not retried into your table.

A signature is over the body, so such an address takes a POST of JSON, a form or text, and refuses a GET or a multipart upload: there are no bytes there to check.

HeaderWhat it holds
x-10lines-timestamp the time it was sent, in Unix seconds
x-10lines-signature sha256= and the hex HMAC-SHA256, keyed with the secret, of the timestamp, a dot, and the raw body

A script in another project sends exactly that with http(url, json=..., sign="SECRET_NAME"), so one project can feed another with nothing in between. Your own server can check the same signature when a script posts to it:

# Python — Flask
import hmac, hashlib, time
from flask import Flask, request

SECRET = b"the value of SYNC_SECRET"
app = Flask(__name__)


@app.post("/hooks/10lines")
def receive():
    body = request.get_data()
    timestamp = request.headers.get("x-10lines-timestamp", "")
    signature = request.headers.get("x-10lines-signature", "")

    # Refuse anything old, so a request somebody captured cannot be replayed.
    if abs(time.time() - int(timestamp)) > 300:
        return "stale", 400

    expected = "sha256=" + hmac.new(
        SECRET, f"{timestamp}.".encode() + body, hashlib.sha256
    ).hexdigest()

    if not hmac.compare_digest(expected, signature):
        return "bad signature", 400

    return "", 200
// Node — Express
const crypto = require("crypto")
const express = require("express")

const SECRET = "the value of SYNC_SECRET"
const app = express()

// The raw body is what was signed, so keep it before anything parses it.
app.post("/hooks/10lines", express.raw({type: "application/json"}), (req, res) => {
  const timestamp = req.get("x-10lines-timestamp")
  const signature = req.get("x-10lines-signature") || ""

  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
    return res.status(400).send("stale")
  }

  const expected = "sha256=" + crypto
    .createHmac("sha256", SECRET)
    .update(`${timestamp}.`)
    .update(req.body)
    .digest("hex")

  const ok = expected.length === signature.length &&
    crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))

  if (!ok) { return res.status(400).send("bad signature") }

  res.sendStatus(200)
})

The timestamp is checked too: a request captured from a log five minutes ago is still good, one from yesterday is not. That is the attack people forget — a body and its signature stay valid together for ever unless something says otherwise.

Stripe, Shopify, GitHub

They sign in schemes of their own, which an address does not check. For them the address token is what keeps strangers out, so guard it. When an event decides something that matters, the handler can ask the sender's own API whether it is true — fetch the payment by its id — before writing it down.

2. The events #

One address takes as many kinds of event as you list. That is the point of it: an application that sends order.paid and order.refunded has one URL field to fill in, not two, and your code branches on the name.

Leave the list empty and every event is accepted. Fill it in and anything else is recorded as not listened for and answered with a 200 — the sender did nothing wrong, and one told "error" will simply keep trying.

There are four ways a request can name its event, and they are tried in order:

WhereLooks likeWhen to use it
The path .../whin_abc.../order.paid Nothing to configure and nothing to guess. Use this whenever the sender lets you type the whole URL.
A header x-10lines-event: order.paid What a script's own http(..., sign=...) sends. Shopify's x-shopify-topic and GitHub's x-github-event are read the same way.
The body {"event": "order.paid", ...} The field named in Field in the body that names the event (event by default), then type and event_type. Between them that covers most of what is out there — Stripe's type, for one, works with nothing set.
Nothing default A sender that names nothing still reaches a script that asked for nothing in particular.

3. The code #

Define main(event). It is handed one dict and writes what it needs to with db["shop"].sql(). The shortest useful script is one line:

def main(event):
    db["shop"].sql("INSERT INTO posts (title) VALUES (?)", [event["payload"]["name"]])
The same, in TypeScript
export default async function main(event: CmsEvent) {
  await db.source("shop").sql("INSERT INTO posts (title) VALUES (?)", [event.payload.name]);
}

This is the dict it is given:

event
{
  "id": 913,                              # this event's id in the log
  "event": "order.paid",                  # the name it arrived under
  "payload": {"id": "A-1001", ...},       # the body that was posted
  "headers": {"content-type": "application/json", "user-agent": "..."},
  "query": {"source": "checkout"},        # the query string, if any
  "received_at": "2026-09-16T10:31:07Z",
  "script": {"id": 4, "name": "Shop orders"}
}

What the sandbox allows

It runs in a sandbox: no filesystem of the host, no environment, no network beyond the hosts the project allowed, and no database connection of its own — db["shop"].sql() is a question asked over a channel that is good for this run and this project only, and stops working when the run ends. It cannot reach another tenant's data, and it cannot change a table's shape.

Besides db, a handler has secrets, email(), download(), http() and two helpers for turning rows into text, without importing anything. Every one of them, in both languages, is in Built-in functions.

What main returns #

Whatever you like, or nothing. It is not written anywhere: it is shown with the run, which makes return the quickest way to see a value while you write the code. Anything JSON can hold will do.

def main(event):
    rows = db["shop"].sql("SELECT status, count(*) AS n FROM orders GROUP BY status")

    # Shown with the run, as JSON. Nothing is written from it.
    return {row["status"]: row["n"] for row in rows}

An event you mean to skip is just a return before anything is written — a success that did nothing, not a failure, and nothing to retry.

def main(event):
    # Only paid orders matter here. Anything else is a success that did
    # nothing — not an error, and nothing to retry.
    if event["event"] != "order.paid":
        return None

    db["shop"].sql("UPDATE orders SET status = ? WHERE code = ?",
           ["paid", event["payload"]["id"]])

Updating instead of duplicating #

Senders deliver the same event twice on purpose when they are not sure the first one landed, and order.updated is the same order again. A handler that only ever INSERTs ends up with two rows for one order. The plainest fix works on every database: look the row up, then update it or add it.

def main(event):
    order = event["payload"]
    existing = db["shop"].get("orders", code=order["id"])

    if existing:
        db["shop"].sql("UPDATE orders SET status = ?, total = ? WHERE id = ?",
               [order["status"], order["total"], existing["id"]])
    else:
        db["shop"].sql("INSERT INTO orders (code, status, total) VALUES (?, ?, ?)",
               [order["id"], order["status"], order["total"]])

Or say it in one statement. On PostgreSQL that is INSERT ... ON CONFLICT (column) DO UPDATE, and it needs a unique index on that column — add one on the schema page, which also stops two rows with the same value from ever existing, however they got there. (MySQL and MariaDB say ON DUPLICATE KEY UPDATE; SQL Server, MERGE.)

def main(event):
    order = event["payload"]

    # PostgreSQL. Needs a unique index on orders.code — add it on the schema page.
    db["shop"].sql(
        "INSERT INTO orders (code, status, total) VALUES (?, ?, ?) "
        "ON CONFLICT (code) DO UPDATE "
        "SET status = excluded.status, total = excluded.total",
        [order["id"], order["status"], order["total"]],
    )

A write happens when it runs

Each statement is written the moment it runs, not when main finishes. Code that raises halfway has already written whatever came before the error, and pressing Run again runs the whole handler again. That is the other reason to write handlers that can safely run twice.
def main(event):
    db["shop"].sql("INSERT INTO orders (code) VALUES (?)", ["A-1"])   # written, now
    raise ValueError("something went wrong down here")

# The row is in the table. A statement is written when it runs, not when
# main finishes — so write handlers that can safely run twice. Look the
# row up first, or use ON CONFLICT, and Run again cannot leave two.

Many rows, several tables #

A run may make 50 statements. A delivery carrying two hundred lines is not two hundred INSERTs; it is one, with a row of placeholders per line:

def main(event):
    lines = event["payload"]["lines"]

    if not lines:
        return

    # One statement for the lot. Only the placeholders are joined into the
    # text — "(?, ?), (?, ?), ..." — and every value still goes in params.
    db["shop"].sql(
        "INSERT INTO order_lines (sku, quantity) VALUES "
        + ", ".join(["(?, ?)"] * len(lines)),
        [value for line in lines for value in (line["sku"], line["qty"])],
    )

Several tables are several statements. A single order.paid can keep a customer current and record the order in one pass:

def main(event):
    order = event["payload"]

    # One event, two tables: keep the customer current and record the order.
    if not db["shop"].get("customers", email=order["email"]):
        db["shop"].sql("INSERT INTO customers (email, name) VALUES (?, ?)",
               [order["email"], order["customer"]])

    if not db["shop"].get("orders", code=order["id"]):
        db["shop"].sql("INSERT INTO orders (code, email, total) VALUES (?, ?, ?)",
               [order["id"], order["email"], order["total"]])

One address, several events #

This is what the list of events is for. The sender has one URL; the code decides what each name means.

# One address, three events. This is the whole reason a script takes a list
# of events rather than one: the sender has a single URL field to fill in.
def main(event):
    order = event["payload"]
    name = event["event"]

    if name == "order.paid":
        if db["shop"].get("orders", code=order["id"]):
            db["shop"].sql("UPDATE orders SET status = ?, total = ? WHERE code = ?",
                   ["paid", order["total"], order["id"]])
        else:
            db["shop"].sql("INSERT INTO orders (code, status, total) VALUES (?, ?, ?)",
                   [order["id"], "paid", order["total"]])

    elif name == "order.shipped":
        db["shop"].sql("UPDATE orders SET status = ?, tracking = ? WHERE code = ?",
               ["shipped", order["tracking"], order["id"]])

    elif name == "order.refunded":
        db["shop"].sql("UPDATE orders SET status = ? WHERE code = ?",
               ["refunded", order["id"]])

    else:
        # An event this handler does not know about. Say so in the log and stop.
        print("ignoring", name)

Testing, and the log #

The script page has a Test body box and a Test button. It runs the code against that body for real, with two exceptions: the writes it makes with db["shop"].sql() are not run but listed as the statements that would run, and no email is sent. Reads see your real data, so a lookup behaves as it will in earnest — but a write's rowcount is 0 in a Test, because nothing changed. A Test is not one of the month's events, but its box time counts toward the month's box minutes.

Run is the real thing: it writes, and it sends. So does every delivery to an address and a clock.

Once something real has arrived, Recent events lists it with the body it came with. Two buttons on an event are worth knowing: Use as test body drops the real payload into the test box — which is the fastest way to write a handler, because you no longer have to guess what the sender sends — and Run again re-runs it after you have fixed something.

print() in code shows up as Output, next to the result and on the event in the log.

StatusWhat it means
waiting Written down, the code has not run yet. Normally a second or less.
handled The script ran to the end. Whatever it returned is shown with it; nothing is a perfectly good answer.
not listened for The script does not list this event. Kept rather than dropped, because this is the answer to almost every "why did nothing happen" — add the name to the list, then press Retry.
failed Tried three times and given up on. The body is still here, so fixing the code and pressing Retry runs the same event again.

Worked examples #

Each of these is a whole script. Paste one in, change the table and column names to match yours, and press Test. There are longer ones — Stripe to QuickBooks, Shopify to QuickBooks — on the examples page.

Stripe — a payment succeeded

# Stripe — a payment succeeded.
#
# Stripe names its events in the body under "type", which is one of the
# fallbacks this application checks, so nothing needs configuring: set the
# script's events to `payment_intent.succeeded` and point Stripe at the
# address as it is.
#
# Stripe sends an event again when it is not sure the first one landed, so
# the payment is looked up by its id before it is added.
def main(event):
    intent = event["payload"]["data"]["object"]

    if db["shop"].get("payments", intent_id=intent["id"]):
        db["shop"].sql("UPDATE payments SET status = ? WHERE intent_id = ?",
               [intent["status"], intent["id"]])
        return

    db["shop"].sql(
        "INSERT INTO payments (intent_id, amount, currency, email, status) "
        "VALUES (?, ?, ?, ?, ?)",
        [
            intent["id"],
            intent["amount"] / 100.0,
            intent["currency"].upper(),
            intent.get("receipt_email") or "",
            intent["status"],
        ],
    )

GitHub — pushes and issues

# GitHub — a push, and an issue opened.
#
# GitHub names the event in the `x-github-event` header, which this
# application reads, so `push` and `issues` arrive as they were sent. Putting
# the name at the end of the address works too, and the path always wins:
#
#     https://10lines.io/hooks/7/whin_.../push
def main(event):
    body = event["payload"]

    if event["event"] == "push":
        commits = body["commits"]

        if commits:
            # One statement for every commit in the push. ON CONFLICT needs a
            # unique index on commits.sha: a commit pushed to two branches
            # arrives twice and is kept once.
            db["shop"].sql(
                "INSERT INTO commits (sha, message, author, repo) VALUES "
                + ", ".join(["(?, ?, ?, ?)"] * len(commits))
                + " ON CONFLICT (sha) DO NOTHING",
                [
                    value
                    for commit in commits
                    for value in (
                        commit["id"],
                        commit["message"].split("\n")[0][:200],
                        commit["author"]["name"],
                        body["repository"]["full_name"],
                    )
                ],
            )

    elif event["event"] == "issues" and body["action"] == "opened":
        issue = body["issue"]

        if not db["shop"].get("issues", number=issue["number"]):
            db["shop"].sql(
                "INSERT INTO issues (number, title, author, url) VALUES (?, ?, ?, ?)",
                [issue["number"], issue["title"], issue["user"]["login"],
                 issue["html_url"]],
            )

Shopify — orders

# Shopify — an order was created or updated.
#
# Shopify sends the topic in the `x-shopify-topic` header, which this
# application reads, so the events arrive as `orders/create` and
# `orders/updated`. Looking the order up by Shopify's id is what makes the
# update arrive as an update.
def main(event):
    order = event["payload"]
    customer = order.get("customer") or {}

    values = [
        order.get("order_number"),
        order.get("email") or "",
        (customer.get("first_name", "") + " " + customer.get("last_name", "")).strip(),
        float(order.get("total_price") or 0),
        order.get("currency") or "",
        str(order["id"]),
    ]

    if db["shop"].get("orders", shopify_id=str(order["id"])):
        db["shop"].sql(
            "UPDATE orders SET number = ?, email = ?, customer = ?, total = ?, "
            "currency = ? WHERE shopify_id = ?",
            values,
        )
    else:
        db["shop"].sql(
            "INSERT INTO orders (number, email, customer, total, currency, shopify_id) "
            "VALUES (?, ?, ?, ?, ?, ?)",
            values,
        )

A contact form on a static site

# A plain HTML form on a static site.
#
#   <form method="post" action="https://10lines.io/hooks/7/whin_.../contact">
#     <input name="name"><input name="email"><textarea name="message"></textarea>
#   </form>
#
# A form post arrives as ordinary fields, so there is nothing to parse. Trim
# what people type, refuse what is empty, and write the rest.
def main(event):
    form = event["payload"]
    message = (form.get("message") or "").strip()

    if not message:
        return None

    db["shop"].sql(
        "INSERT INTO messages (name, email, message, user_agent) VALUES (?, ?, ?, ?)",
        [
            (form.get("name") or "anonymous").strip()[:80],
            (form.get("email") or "").strip()[:200],
            message[:5000],
            event["headers"].get("user-agent", "")[:200],
        ],
    )

A device posting readings

# A device posting a reading every minute.
#
# Two things worth doing when the sender is a device: refuse nonsense before it
# becomes a row, and keep the value in one unit whatever the device sends.
def main(event):
    reading = event["payload"]
    celsius = reading.get("c")

    if celsius is None and "f" in reading:
        celsius = (reading["f"] - 32) * 5 / 9

    if celsius is None or not (-90 <= celsius <= 60):
        print("refusing an impossible reading:", reading)
        return None

    db["shop"].sql(
        "INSERT INTO readings (device, celsius, taken_at) VALUES (?, ?, ?)",
        [
            event["query"].get("device", "unknown"),
            round(celsius, 2),
            reading.get("at") or event["received_at"],
        ],
    )

Another project

# One project feeding another.
#
# In the sending project, a script posts with `http(..., sign=...)`. That
# signs the body with a shared secret and names the event in `x-10lines-event`,
# which an address reads without being told to:
#
#     def main(event):
#         rows = db["shop"].sql("SELECT id, title FROM posts WHERE status = ?", ["published"])
#         http("https://10lines.io/hooks/7/whin_.../posts.sync",
#              json={"rows": rows}, sign="SYNC_SECRET")
#
# In this project, put the same secret in Secrets as SYNC_SECRET and choose
# "A shared secret" under the address. This is the receiving end:
def main(event):
    rows = event["payload"]["rows"]

    if not rows:
        return

    # One statement for the lot. ON CONFLICT needs a unique index on
    # mirrored_posts.source_id, and makes a second sync an update.
    db["shop"].sql(
        "INSERT INTO mirrored_posts (source_id, title) VALUES "
        + ", ".join(["(?, ?)"] * len(rows))
        + " ON CONFLICT (source_id) DO UPDATE SET title = excluded.title",
        [value for row in rows for value in (row["id"], row["title"])],
    )

When it goes wrong #

Code that raises is shown with a traceback that starts at your line, not somewhere inside this application:

main raised KeyError

Traceback (most recent call last):
  File "<handler>", line 2, in main
    db["shop"].sql("INSERT INTO posts (title) VALUES (?)", [event["payload"]["name"]])
                                                    ~~~~~~~~~~~~~~~~^^^^^^^^
KeyError: 'name'

Nearly every one of these is the same thing: the sender did not send what you expected. The fix is nearly always the same too.

# The fix is almost always the same one: the sender did not send what you
# expected. Reach for .get() with a default rather than [].
def main(event):
    payload = event["payload"]
    title = payload.get("name") or payload.get("title") or "untitled"

    db["shop"].sql("INSERT INTO posts (title) VALUES (?)", [title])
What you seeWhat it means
no such script (404) The address is wrong, or it has been rotated, or the script was deleted. The same answer covers all three on purpose — telling the difference is telling somebody they have guessed half of it.
{"status": "disabled"} Accepting events is switched off on the script.
{"status": "ignored"} The event arrived under a name the script does not list. Look at it in the log to see what name it actually used.
the body is larger than 256 KB Bodies are kept as the only record of what was said, so they are capped. A sender this big should post an id and let you read the rest over its own API.
QueryError: column "x" of relation "orders" does not exist The database refused a statement, in its own words. The traceback points at the db["shop"].sql() line; fix the statement or add the column, then press Retry.
DROP is not allowed here A statement tried to change a table's shape. Do that on the schema page.
a script may run 50 queries The run used up its statements. Write many rows in one statement, as above.
the script took too long A script is stopped after a fixed amount of work, which a loop that never ends will always reach.

Calling other services, and packages #

http() reaches only the hosts the project allows, listed under the project's settings, in Secrets, as Where scripts may call: one host a line, and *.example.com for one level of subdomain. An IP address is refused, and so is a name on a private network or one that does not resolve yet. Allowing a host lets a script send it anything the script can read, your rows included, so allow the ones it needs. Where the server runs every script sealed, the page says so and the list has no effect.

A project's packages go in the Packages box on a script's page — a requirements.txt for Python, a package.json for TypeScript — and Install lists the versions that landed. They are the project's, for every script in it. Python gets wheels only and npm runs no install scripts, because those would run code from a file somebody typed; a package that only comes as source is refused with a message saying so. An install that fails leaves the working set as it was. Installing reaches the package index; a script then reads what was installed and is held to the project's hosts like any other call.

Limits #

Body size 256 KB
Deliveries to a project's addresses 600 a minute, then 429 with retry-after; a signed delivery sent twice is recorded once
Statements per run 50
Rows one read returns 200
Handler memory 256 MB
Handler wall clock 30 seconds, and a fuel budget that stops an endless loop sooner
Attempts before giving up 3, immediately then after a minute then after five
Log kept for 14 days for handled events; failures are kept until you deal with them

Ready to make one? Open a project and go to Scripts in the sidebar — or start from a whole integration.