Integrations

Lovable, Bolt and Supabase

Use JobsPipe in an app built with Lovable or Bolt - keep the API key in a Supabase Edge Function secret, call JobsPipe from the function, and call the function from your app. Includes a complete Edge Function and a prompt to paste into the builder.

Lovable and Bolt build apps whose code runs in the browser, with a Supabase project as the backend. To show JobsPipe jobs in such an app, put a small Supabase Edge Function between the app and JobsPipe: the function holds your API key and calls POST /v1/jobs/search, and the app calls the function.

Three steps: your Lovable or Bolt app in the browser holds no API key and calls a Supabase Edge Function; the Edge Function reads the JOBSPIPE_API_KEY secret and calls api.jobspipe.dev with it; JobsPipe answers POST /v1/jobs/search with jobs, a next cursor and the credits charged

Never put the API key in the app

Do not paste your jp_live_ key into a Lovable or Bolt chat, a React component, a .env file with a VITE_ prefix, or any other front-end code. Everything the browser loads is public: anyone who opens your site can read the key from the page source or the network tab and spend your credits.

The key belongs in a Supabase secret. Edge Functions read secrets with Deno.env.get(...), and they never reach the browser. If a key was ever shipped in front-end code, revoke it and create a new one.

Before you start

  • A JobsPipe API key. Create one in the dashboard under Settings → API Keys; it starts with jp_live_. See Authentication.
  • A Supabase project connected to your Lovable or Bolt app. Both builders can create one for you.
  • Optional: the Supabase CLI, to set the secret and deploy from your own machine.

To wire everything up before you have a key, point the function at the sandbox: it takes the same body with no key and returns two fixed sample jobs. See Test with the sandbox.

Store the key as a Supabase secret

supabase secrets set JOBSPIPE_API_KEY=jp_live_your_key_here
Project → Edge Functions → Secrets → Add new secret
  Name:  JOBSPIPE_API_KEY
  Value: jp_live_your_key_here

Set the secret yourself rather than pasting the key into the builder's chat. If Lovable or Bolt asks for the key, use its secret or API key form, which stores it as a Supabase secret, not the chat box.

The Edge Function

Save this as supabase/functions/jobs-search/index.ts. It accepts a small JSON body from your app, checks it, turns it into JobsPipe filters, calls JobsPipe with the key from the secret, and returns a trimmed list of jobs with CORS headers so the browser can read it.

supabase/functions/jobs-search/index.ts
const JOBSPIPE_URL = "https://api.jobspipe.dev/v1/jobs/search";
const MAX_LIMIT = 25;

const corsHeaders = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
  "Access-Control-Allow-Methods": "POST, OPTIONS",
};

const errorHints: Record<number, string> = {
  400: "JobsPipe rejected the search filters.",
  401: "The JobsPipe API key is missing, invalid or revoked. Check the JOBSPIPE_API_KEY secret.",
  402: "The JobsPipe account is out of credits.",
  403: "The JobsPipe account is not allowed to use the API.",
  429: "Too many requests per second. Wait a moment and try again.",
  502: "The search could not be completed. Try again.",
  504: "The search timed out. Try again, or narrow the filters.",
};

type SearchInput = {
  titles: string[];
  countries: string[];
  remote?: boolean;
  maxAgeDays?: number;
  limit: number;
  cursor?: string;
};

function json(body: unknown, status = 200): Response {
  return new Response(JSON.stringify(body), {
    status,
    headers: { ...corsHeaders, "Content-Type": "application/json" },
  });
}

function stringList(value: unknown, max: number): string[] | null {
  if (value === undefined) return [];
  if (!Array.isArray(value) || value.length > max) return null;
  const cleaned = value.map((item) => (typeof item === "string" ? item.trim() : ""));
  return cleaned.every((item) => item.length > 0 && item.length <= 100) ? cleaned : null;
}

function parseInput(raw: unknown): SearchInput | string {
  if (typeof raw !== "object" || raw === null) return "Send a JSON object.";
  const body = raw as Record<string, unknown>;

  const titles = stringList(body.titles, 10);
  if (!titles || titles.length === 0) return "titles must be 1 to 10 non-empty strings.";

  const countries = stringList(body.countries, 10);
  if (!countries || !countries.every((code) => /^[A-Za-z]{2}$/.test(code))) {
    return "countries must be two-letter ISO codes, e.g. [\"US\", \"GB\"].";
  }

  if (body.remote !== undefined && typeof body.remote !== "boolean") {
    return "remote must be true or false.";
  }

  const maxAgeDays = body.maxAgeDays;
  if (
    maxAgeDays !== undefined &&
    (!Number.isInteger(maxAgeDays) || (maxAgeDays as number) < 1 || (maxAgeDays as number) > 365)
  ) {
    return "maxAgeDays must be a whole number from 1 to 365.";
  }

  const limit = body.limit ?? 10;
  if (!Number.isInteger(limit) || (limit as number) < 1 || (limit as number) > MAX_LIMIT) {
    return `limit must be a whole number from 1 to ${MAX_LIMIT}.`;
  }

  if (body.cursor !== undefined && typeof body.cursor !== "string") {
    return "cursor must be the next_cursor string from the previous page.";
  }

  return {
    titles,
    countries: countries.map((code) => code.toUpperCase()),
    remote: body.remote as boolean | undefined,
    maxAgeDays: maxAgeDays as number | undefined,
    limit: limit as number,
    cursor: body.cursor as string | undefined,
  };
}

function toJobsPipeFilters(input: SearchInput): Record<string, unknown> {
  const filters: Record<string, unknown> = {
    job_title_or: input.titles,
    limit: input.limit,
  };
  if (input.countries.length > 0) filters.job_country_code_or = input.countries;
  if (input.remote !== undefined) filters.remote = input.remote;
  if (input.maxAgeDays !== undefined) filters.posted_at_max_age_days = input.maxAgeDays;
  if (input.cursor) filters.cursor = input.cursor;
  return filters;
}

Deno.serve(async (req) => {
  if (req.method === "OPTIONS") return new Response("ok", { headers: corsHeaders });
  if (req.method !== "POST") return json({ error: "Use POST." }, 405);

  const apiKey = Deno.env.get("JOBSPIPE_API_KEY");
  if (!apiKey) return json({ error: "JOBSPIPE_API_KEY is not set on this project." }, 500);

  let raw: unknown;
  try {
    raw = await req.json();
  } catch {
    return json({ error: "The request body is not valid JSON." }, 400);
  }

  const input = parseInput(raw);
  if (typeof input === "string") return json({ error: input }, 400);

  let upstream: Response;
  try {
    upstream = await fetch(JOBSPIPE_URL, {
      method: "POST",
      headers: {
        Authorization: `Bearer ${apiKey}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify(toJobsPipeFilters(input)),
    });
  } catch {
    return json({ error: "Could not reach JobsPipe. Try again." }, 502);
  }

  const payload = await upstream.json().catch(() => null);

  if (!upstream.ok) {
    return json(
      {
        error: errorHints[upstream.status] ?? "The job search failed.",
        detail: payload?.error ?? null,
        status: upstream.status,
      },
      upstream.status,
    );
  }

  return json({
    jobs: payload.data.map((job: Record<string, unknown>) => ({
      id: job.id,
      title: job.job_title,
      company: job.company,
      location: job.location,
      remote: job.remote,
      posted: job.date_posted,
      url: job.source_url,
    })),
    nextCursor: payload.metadata.next_cursor,
    creditsCharged: payload.metadata.credits_charged,
  });
});

What the function does:

  • Checks the input before it spends anything. A search that JobsPipe rejects with 400 still costs 1 credit, so the function refuses bad input itself: 1 to 10 titles, two-letter country codes, a boolean remote, maxAgeDays from 1 to 365.
  • Caps the page size. limit is at most 25 per call, the page size on Free. Each job costs one credit the first time you receive it in a calendar month, so a cap keeps a busy page from spending more than you expect. Raise MAX_LIMIT if your plan allows larger pages and you want them.
  • Maps your app's names to JobsPipe filters. titles becomes job_title_or, countries becomes job_country_code_or, maxAgeDays becomes posted_at_max_age_days, and remote, limit and cursor pass through. To add a filter, add a field to parseInput and map it in toJobsPipeFilters, using a name from the filter reference. JobsPipe rejects names it does not have.
  • Passes errors through. A JobsPipe error keeps its status code and comes back as { "error", "detail", "status" }, where detail is the message from JobsPipe.
  • Pages with a cursor. nextCursor is JobsPipe's metadata.next_cursor. Send it back as cursor with the same filters for the next page; it is null on the last page. See pagination.

Errors your app should handle

StatusMeaningWhat the app should do
400The function or JobsPipe rejected the input.Show the error message. Do not retry the same input.
401The JOBSPIPE_API_KEY secret is missing, wrong or revoked.Fix the secret, then redeploy. Not something a visitor can fix.
402The JobsPipe account is out of credits.Show "search is unavailable". Retrying does not help: wait for the monthly reset, buy credits or upgrade.
403The JobsPipe account is not allowed to use the API.See 403 Forbidden.
429Too many requests in one second for your plan.Wait a moment and retry, with backoff.
502, 504The search failed or timed out.Retry; for a 504, narrow the filters.

401, 402 and 429 cost nothing, and neither does a search that fails with 502 or 504. See errors and rate limits.

Deploy it

Lovable and Bolt deploy Edge Functions they create in your connected Supabase project. From your own machine, deploy with the CLI:

supabase functions deploy jobs-search

Supabase checks the caller's Supabase token on every Edge Function call by default, and supabase.functions.invoke in your app sends it for you. Keep that check on.

The Supabase anon key in your app is public, so anyone can call a function that only needs the anon key. If your app has sign-in, make the function answer only signed-in users, and keep the limit cap. Every job the function returns is charged to your JobsPipe account.

Call it from the app

import { supabase } from "@/integrations/supabase/client";

const { data, error } = await supabase.functions.invoke("jobs-search", {
  body: { titles: ["data engineer"], countries: ["US"], remote: true, maxAgeDays: 7, limit: 10 },
});

if (error) {
  const { error: message } = await error.context.json();
  showError(message);
} else {
  renderJobs(data.jobs);
}

The import path is where Lovable puts the Supabase client; in a Bolt project, import the client from wherever the project creates it. showError and renderJobs stand for your own UI code.

Prompt to paste into Lovable or Bolt

Set the JOBSPIPE_API_KEY secret first, then paste this into the chat. It asks for the function above and a search page, and tells the builder where the key must stay.

Add job search powered by the JobsPipe API.

1. Create a Supabase Edge Function named "jobs-search" with exactly the code from
   https://docs.jobspipe.dev/integrations/lovable-bolt-supabase (section "The Edge Function").
   It reads the API key with Deno.env.get("JOBSPIPE_API_KEY"). The secret is already set in
   Supabase. Never put the key in front-end code, a VITE_ variable, or this chat.

2. Build a job search page that calls the function with
   supabase.functions.invoke("jobs-search", { body }), where body is
   { titles: string[], countries: string[], remote?: boolean, maxAgeDays?: number, limit?: number, cursor?: string }.
   The form has: job titles (comma separated), countries as two-letter codes (for example US, GB),
   a "remote only" toggle, and "posted in the last N days".

3. Show each job in data.jobs as a card with title, company, location, a Remote badge when
   remote is true, the posted date, and an "Apply" link to url that opens in a new tab.

4. Show a "Load more" button while data.nextCursor is not null. It sends the same filters plus
   cursor: data.nextCursor and appends the results.

5. On an error, read the JSON body of the error response and show its "error" message.
   Show a loading state while a search runs. Do not search on every keystroke; search when the
   form is submitted.

The result is a page like this one: a search form, one card per job with a Remote badge and an Apply link, and a Load more button.

A job search page built in Lovable: a form with job titles, countries, posted in the last 7 days and a Remote only toggle, then a grid of remote data engineer jobs with company, location, posted date and an Apply link

If the builder rewrites the function, check that it still reads the key from Deno.env.get("JOBSPIPE_API_KEY"), still calls https://api.jobspipe.dev/v1/jobs/search, and only sends filter names from the filter reference.

Test with the sandbox

To build the UI before you have a key or credits, change JOBSPIPE_URL in the function to the no-key sandbox:

const JOBSPIPE_URL = "https://api.jobspipe.dev/v1/sandbox/jobs/search";

The sandbox returns the same two sample jobs whatever the filters say and needs no secret, although the function still expects JOBSPIPE_API_KEY to be set; any value works. When the page works, change the URL back and set your real key. See Prototype with the sandbox.

On this page