Connect Loops to anything

Loops' public REST API is language-agnostic. Websites, mobile apps, WhatsApp/Telegram/Discord bots, Slack, GitHub Actions, even cron jobs — any HTTP client with a bearer token works.

REST APIWebhooksJS WidgetBot templatesSelf-hosted

3 minutes

Quick start

  1. 1
    Sign in to Loops and get admin access.
  2. 2
    Settings → API Keys page, create a secret or publishable key.
  3. 3
    Try the API with cURL:
    bash
    curl -H "Authorization: Bearer loop_sk_..." \
      https://your-loop.app/api/v1/posts

HTML

Embed widget

Drop a feedback board into any HTML page with a single line. Vanilla JS, ~8KB, no dependencies.

html
<div id="loop-board"></div>
<script src="https://your-loop.app/loop-widget.js"
        data-key="loop_pk_..."
        data-host="https://your-loop.app"
        data-target="#loop-board"
        data-theme="light"
        data-locale="tr"></script>

data-key: publishable key (loop_pk_…). Never put a secret key in the browser.

data-theme: light or dark.

data-locale: tr or en.

data-target: CSS selector where the widget mounts.

Bearer auth

REST API

All endpoints are JSON. CORS is open. Every request needs Authorization: Bearer <key>. Errors come back as { error: { code, message } }.

GET/api/v1/posts

List all feedback items.

status
queryplanned | progress | done
tag
queryfilter by tag
limit
querydefault 50, max 200
offset
queryfor pagination
curl
curl -H "Authorization: Bearer loop_sk_..." \
  "https://your-loop.app/api/v1/posts?status=planned&limit=10"
POST/api/v1/posts

Create a new post (write scope).

title
body (string, 3-140)required
description
body (string)optional
tag
body (string)optional
external_user_id
body (string)anonymous user id (for bots/embed)
source
body (string)e.g. telegram, embed, mobile
curl
curl -X POST -H "Authorization: Bearer loop_sk_..." \
  -H "Content-Type: application/json" \
  -d '{"title":"Dark theme please","tag":"UI","source":"telegram"}' \
  https://your-loop.app/api/v1/posts
POST/api/v1/posts/:id/vote

Vote / unvote on a post (toggle).

external_user_id
body / X-Loop-External-User headeranonymous user id, required
curl
curl -X POST -H "Authorization: Bearer loop_pk_..." \
  -H "X-Loop-External-User: tg_user_12345" \
  https://your-loop.app/api/v1/posts/POST_ID/vote
PATCH/api/v1/posts/:id

Update a post's status/tag (admin scope).

status
bodyplanned | progress | done
tag
bodynew tag
DELETE/api/v1/posts/:id

Delete a post (admin scope).

Outgoing

Webhooks

When a new post / vote / status change happens in Loops, we POST to your URL. Bridge it to Slack, Discord, Linear, GitHub Issues or anything else.

Every request carries an X-Loop-Signature header — your webhook secret signed with HMAC-SHA256(body). Node verification example below:

js
import crypto from "node:crypto";

function verify(req, secret) {
  const sig = req.headers["x-loop-signature"];
  const expected = crypto.createHmac("sha256", secret)
    .update(req.rawBody).digest("hex");
  return sig && crypto.timingSafeEqual(
    Buffer.from(sig), Buffer.from(expected)
  );
}

Sample webhook payload: { event: "post.created", post: { id, title, ... }, timestamp }

Python

Telegram bot

Bot that posts to Loops with the /feedback <text> command. ~30 lines.

bash
pip install python-telegram-bot requests
export TELEGRAM_TOKEN=...
export LOOP_KEY=loop_sk_...
export LOOP_HOST=https://your-loop.app
python
import os, requests
from telegram import Update
from telegram.ext import Application, CommandHandler, ContextTypes

LOOP_HOST = os.environ["LOOP_HOST"]
LOOP_KEY  = os.environ["LOOP_KEY"]

async def feedback(update: Update, ctx: ContextTypes.DEFAULT_TYPE):
    text = " ".join(ctx.args).strip()
    if len(text) < 3:
        await update.message.reply_text("Usage: /feedback <your idea>")
        return
    user = update.effective_user
    r = requests.post(
        f"{LOOP_HOST}/api/v1/posts",
        headers={"Authorization": f"Bearer {LOOP_KEY}"},
        json={
            "title": text[:140],
            "source": "telegram",
            "external_user_id": f"tg_{user.id}",
            "tag": "telegram",
        },
    )
    if r.ok:
        await update.message.reply_text("✓ Got it, check the board.")
    else:
        await update.message.reply_text(f"Error: {r.text}")

app = Application.builder().token(os.environ["TELEGRAM_TOKEN"]).build()
app.add_handler(CommandHandler("feedback", feedback))
app.run_polling()

Node + Twilio

WhatsApp bot

Convert messages received via Twilio's WhatsApp Sandbox into Loops posts. Point Twilio's webhook at this Express endpoint.

bash
npm i express twilio body-parser
# Twilio Sandbox webhook URL: https://your-bot.com/wa
js
import express from "express";
import twilio from "twilio";

const app = express();
app.use(express.urlencoded({ extended: false }));

app.post("/wa", async (req, res) => {
  const body = (req.body.Body || "").trim();
  const from = req.body.From; // "whatsapp:+90555..."

  const r = await fetch(`${process.env.LOOP_HOST}/api/v1/posts`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.LOOP_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      title: body.slice(0, 140),
      source: "whatsapp",
      external_user_id: from,
      tag: "whatsapp",
    }),
  });

  const twiml = new twilio.twiml.MessagingResponse();
  twiml.message(r.ok ? "✓ Got it, posted to the board." : "❌ Something went wrong.");
  res.type("text/xml").send(twiml.toString());
});

app.listen(3000);

Node + discord.js

Discord bot

Minimal bot that posts to Loops via the /feedback slash command.

bash
npm i discord.js
# Create a bot + applications.commands scope in the Discord Developer Portal
js
import {
  Client, GatewayIntentBits, REST, Routes,
  SlashCommandBuilder,
} from "discord.js";

const cmd = new SlashCommandBuilder()
  .setName("feedback")
  .setDescription("Send feedback to Loops")
  .addStringOption(o => o.setName("text").setDescription("Your idea").setRequired(true));

const rest = new REST({ version: "10" }).setToken(process.env.DISCORD_TOKEN);
await rest.put(
  Routes.applicationCommands(process.env.DISCORD_APP_ID),
  { body: [cmd.toJSON()] }
);

const client = new Client({ intents: [GatewayIntentBits.Guilds] });
client.on("interactionCreate", async (i) => {
  if (!i.isChatInputCommand() || i.commandName !== "feedback") return;
  const text = i.options.getString("text", true);
  const r = await fetch(`${process.env.LOOP_HOST}/api/v1/posts`, {
    method: "POST",
    headers: {
      Authorization: `Bearer ${process.env.LOOP_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      title: text.slice(0, 140),
      source: "discord",
      external_user_id: `dc_${i.user.id}`,
      tag: "discord",
    }),
  });
  await i.reply({ content: r.ok ? "✓ Added." : "❌ Error.", ephemeral: true });
});

client.login(process.env.DISCORD_TOKEN);