Skip to content

Integration guide

HurrySign is a website and an API. Keep API keys on your server. The browser only ever sees a short-lived signing URL.

Machine-readable spec: /api/v1/openapi.json

Partner accounts and sub-organizations

A firm that only sends its own agreements uses one organization. A product such as a claims CRM or a mitigation platform turns on “partner” in settings, then calls POST /api/v1/sub-organizations. Each customer can be a child organization billed to the partner (billingMode: parent) or billed on its own. Act as a child by sending X-HurrySign-Organization: <child id> with the partner key, or store the API key returned when the child is created.

Sending, routing, and the ceremony

Create an envelope with signingOrder of parallel or sequential. Parallel is the default: every signer is invited and can open their link without waiting. Pass sequential to turn signing order on. In that mode, recipients who share a routing number sign together, and the next number waits. If you omit routingOrder on a sequential envelope, the recipient is placed on the next step. On a parallel envelope, an omitted number stays on step 1 and does not block anyone. Set expiresInDays (1–365) and reminderIntervalDays (0–30, where 0 turns automatic reminders off). A daily job, authorized with CRON_SECRET, expires due envelopes and sends reminders.

Void with POST /api/v1/envelopes/:id/void and a reason. Signers are emailed and their links stop working. POST .../duplicate copies the PDF and fields into a new draft and leaves the original in place. POST .../revise with { reason } is how you change documents after send: the sent files stay on the original envelope, that envelope is voided, and you get a new draft. Signers review and sign only after you send the new draft. Uploading another PDF onto an envelope that is no longer a draft is rejected. POST .../resend issues a fresh link to whoever can sign now. POST .../recipients/:recipientId corrects a name, email, or phone on an envelope that is still out; the old link stops working.

A signer can decline with a reason. The envelope moves to declined, the sender is emailed, and envelope.declined is delivered to webhooks that subscribe to it. Each signer can be asked for an access code you share separately. Without that code, the email link opens the document.

Field types are signature, initials, date signed, full name, text, checkbox, radio, dropdown, number, email, and date. Text can be optional. Radio choices share a groupKey. showIfFieldId and showIfValue hide a field until another field matches. Carbon-copy recipients get the completed PDF and do not sign. The completed file includes a certificate of completion: each signer, email, IP, timestamps, how they authenticated, the audit events, and the SHA-256 of each document. The hash of the finished file is stored in the audit trail.

Webhook events: envelope.sent, recipient.viewed, recipient.signed, envelope.completed, envelope.declined, envelope.voided, envelope.expired, envelope.reminded, envelope.resent, recipient.corrected, and bulk.completed. Deliveries that fail are retried by the webhook cron. Settings shows the last deliveries and a Retry now button. Verify the signature before you trust the body.

Bulk send and public links

POST /api/v1/templates/:id/bulk takes a CSV ({ csv } or a multipart file) and creates one envelope per row, up to 50. A template with one signer uses name,email. Extra signer roles use columns named Role name and Role email. A sample file is bulk-recipients.csv. Each successful row fires envelope.sent. The batch fires bulk.completed.

POST /api/v1/templates/:id/public publishes a template that has exactly one signer role. The response includes a public URL and an ?embed=1 URL you can place in an iframe. The visitor enters a name and email. Optional emailCode: true emails a 6-digit code first. The form is rate limited. Publishing requires confirmExclusions: true.

Teams and branding

Owners invite admins, members, and viewers. Templates and envelopes belong to the organization, so the whole team shares them. The envelope list filters by status and by subject, name, or email. Viewers cannot send or edit.

GET /api/v1/organization returns brand.name, brand.color, and brand.logoUrl. Set them with POST /api/v1/organization/brand and POST /api/v1/organization/logo. The company name, colour, and logo appear on the signing page, in signing emails, and in embedded signing, which is the same page. A colour is used on buttons only when white text stays readable.

Sandbox keys

Prefer keys that start with hs_test_. Settings creates those unless you confirm a live key. A sandbox send does not deliver email, and the finished PDF is stamped TEST — not binding. Do not treat a test key as a real signature. A live key from Settings starts with hs_live_. Failed requests return { error: { code, message } }.

Create from a template and prefill

Send templateId and roles so each template role gets a person. prefill sets values the signer sees, matched by field label or fieldId. The template fields need those labels. confirmExclusions is still required on send.

Create and send — curl

curl -s https://hurrysign.com/api/v1/envelopes \
  -H "Authorization: Bearer $HURRYSIGN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"subject":"Service agreement","recipients":[{"name":"Ada Lovelace","email":"ada@example.com","role":"signer"}]}'

Create and send — JavaScript

const base = "https://hurrysign.com";
const headers = { Authorization: "Bearer " + process.env.HURRYSIGN_API_KEY, "Content-Type": "application/json" };

const created = await fetch(base + "/api/v1/envelopes", {
  method: "POST",
  headers,
  body: JSON.stringify({
    subject: "Service agreement",
    recipients: [{ name: "Ada Lovelace", email: "ada@example.com", role: "signer" }],
  }),
}).then((res) => res.json());

const form = new FormData();
form.append("file", new Blob([fs.readFileSync("agreement.pdf")], { type: "application/pdf" }), "agreement.pdf");
const uploaded = await fetch(base + "/api/v1/envelopes/" + created.envelope.id + "/documents", {
  method: "POST",
  headers: { Authorization: headers.Authorization },
  body: form,
}).then((res) => res.json());

await fetch(base + "/api/v1/envelopes/" + created.envelope.id + "/fields", {
  method: "POST",
  headers,
  body: JSON.stringify({
    documentId: uploaded.document.id,
    recipientId: created.envelope.recipients[0].id,
    type: "signature",
    anchor: "{{sig_1}}",
  }),
});

await fetch(base + "/api/v1/envelopes/" + created.envelope.id + "/send", {
  method: "POST",
  headers,
  body: JSON.stringify({ confirmExclusions: true }),
});

Create and send — PHP

<?php
$base = "https://hurrysign.com";
$key = getenv("HURRYSIGN_API_KEY");

function hurrysign_json($method, $url, $key, $body = null) {
  $ch = curl_init($url);
  curl_setopt_array($ch, [
    CURLOPT_CUSTOMREQUEST => $method,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => ["Authorization: Bearer $key", "Content-Type: application/json"],
    CURLOPT_POSTFIELDS => $body ? json_encode($body) : null,
  ]);
  $raw = curl_exec($ch);
  curl_close($ch);
  return json_decode($raw, true);
}

$created = hurrysign_json("POST", "$base/api/v1/envelopes", $key, [
  "subject" => "Service agreement",
  "recipients" => [["name" => "Ada Lovelace", "email" => "ada@example.com", "role" => "signer"]],
]);
$id = $created["envelope"]["id"];

$ch = curl_init("$base/api/v1/envelopes/$id/documents");
curl_setopt_array($ch, [
  CURLOPT_POST => true,
  CURLOPT_RETURNTRANSFER => true,
  CURLOPT_HTTPHEADER => ["Authorization: Bearer $key"],
  CURLOPT_POSTFIELDS => ["file" => new CURLFile("agreement.pdf", "application/pdf", "agreement.pdf")],
]);
$uploaded = json_decode(curl_exec($ch), true);
curl_close($ch);

hurrysign_json("POST", "$base/api/v1/envelopes/$id/fields", $key, [
  "documentId" => $uploaded["document"]["id"],
  "recipientId" => $created["envelope"]["recipients"][0]["id"],
  "type" => "signature",
  "page" => 1, "x" => 0.12, "y" => 0.72, "width" => 0.42, "height" => 0.046,
]);
hurrysign_json("POST", "$base/api/v1/envelopes/$id/send", $key, ["confirmExclusions" => true]);

Template with prefilled fields — curl

curl -s https://hurrysign.com/api/v1/envelopes \
  -H "Authorization: Bearer $HURRYSIGN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "subject": "Claim authorization",
    "templateId": "TEMPLATE_ID",
    "roles": [{ "roleName": "Insured", "name": "Ada Lovelace", "email": "ada@example.com" }],
    "prefill": [{ "label": "Property address", "value": "123 Main St" }]
  }'

Embed signing

<script src="https://hurrysign.com/embed.js"></script>
<div id="signing"></div>
<script>
  HurrySign.embed({
    url: SIGNING_URL,
    container: "#signing",
    height: "720px",
    onEvent: function (event) { console.log(event.event, event.envelopeId); }
  });
</script>

// The signing URL comes from your server. It is an /embed/sign/ link, not the email link.
// POST /api/v1/envelopes/:id/recipients/:recipientId/signing-url
// { "returnUrl": "https://app.example.com/claims/42", "expiresInSeconds": 900 }
// Framing is allowed for that embed URL only. Set EMBED_FRAME_ANCESTORS to your https origins.

Embed sending

<!-- Your server creates the envelope with the API key, then the browser only loads the signing URL. -->
<iframe src="SIGNING_URL" title="Sign the agreement" style="width:100%;height:780px;border:0"></iframe>
<script src="https://hurrysign.com/embed.js"></script>
<script>
  HurrySign.embed({ url: SIGNING_URL, container: "#signing", height: "780px" });
</script>

Verify a webhook — Node

const crypto = require("crypto");

function verifyHurrySignWebhook(secret, rawBody, header) {
  const parts = Object.fromEntries(String(header).split(",").map((part) => {
    const [key, ...rest] = part.split("=");
    return [key, rest.join("=")];
  }));
  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(parts.t));
  if (!parts.v1 || age > 300) return false;
  const expected = crypto.createHmac("sha256", secret).update(parts.t + "." + rawBody).digest("hex");
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
}

Verify a webhook — PHP

<?php
function hurrysign_verify($secret, $rawBody, $header) {
  $parts = [];
  foreach (explode(",", $header) as $piece) {
    [$k, $v] = array_pad(explode("=", $piece, 2), 2, "");
    $parts[$k] = $v;
  }
  if (empty($parts["v1"]) || abs(time() - intval($parts["t"])) > 300) return false;
  $expected = hash_hmac("sha256", $parts["t"] . "." . $rawBody, $secret);
  return hash_equals($expected, $parts["v1"]);
}