Saltar al contenido

Documentación · API v1

API de certificados digitales

Compra, consulta, renueva, revoca y descarga certificados digitales para el SII desde tu sistema, y recibe un aviso firmado cada vez que uno cambia de estado. REST y JSON, sin SDK obligatorio y sin costo aparte.

Contenido

Introducción

La API te deja hacer desde tu sistema lo mismo que en la tienda: comprar un certificado para un titular, mandarlo a pagar, saber en qué va, descargarlo cuando se emite y renovarlo antes de que venza. Los cambios de estado te llegan por webhook, firmados con un secreto tuyo.

La URL base es:

URL
https://hankos.cl/api/v1
  • Todo es JSON en UTF-8. Manda Content-Type: application/json cuando hay cuerpo.
  • Las fechas van en ISO 8601, en UTC (2027-09-27T16:20:02Z).
  • Los montos son pesos chilenos (CLP), enteros, con IVA incluido.
  • Los RUT se reciben con o sin puntos, y se devuelven sin puntos y con guion (12345678-5).
  • Sólo HTTPS. Las respuestas no se guardan en caché.

En tres pasos

  1. Entra a tu cuenta con tu correo y crea una llave de API.
  2. Prueba la llave con GET /prices.
  3. Registra un webhook y compra con POST /certificates.
Tu primera llamadabash
curl "https://hankos.cl/api/v1/prices" \
  -H "Authorization: Bearer $ASTROBIT_API_KEY"

Autenticación

Cada solicitud lleva tu llave de API en la cabecera Authorization:

HTTP
Authorization: Bearer ak_live_…

Una llave es ak_live_ seguido de 40 letras y números. Las creas en /cuenta, donde entras con un enlace que te llega al correo, sin contraseña. La llave completa se muestra una sola vez, al crearla: nosotros guardamos sólo una huella, su prefijo (ak_live_ y los 4 primeros caracteres) y los 4 últimos, que es lo que ves después en la lista. Puedes tener hasta 10 llaves activas.

Una cuenta es un correo. La API ve los certificados donde ese correo es el del comprador o el del titular.

Alcances

Al crear una llave eliges qué puede hacer; al menos un alcance.

AlcancePermite
certificates:readListar y ver certificados, precios y eventos.
certificates:writeComprar, renovar, revocar y pedir enlaces de pago.
certificates:downloadDescargar el .pfx y su contraseña.
webhooks:manageAdministrar webhooks. Para crear uno, o cambiar su URL o sus eventos, también certificates:read: los eventos llevan los datos del certificado.

Sin llave, la respuesta es 401 missing_api_key; con una que no existe, invalid_api_key; con una revocada, revoked_api_key. Si a la llave le falta el alcance, 403 insufficient_scope, con el alcance que falta en error.scope.

Buenas prácticas

  • Guárdala en una variable de entorno o en un gestor de secretos, nunca en el código ni en el repositorio.
  • Úsala sólo desde tu servidor. Nunca en un navegador ni en una app móvil: quien la vea, la puede usar.
  • Una llave por sistema, con el mínimo de alcances. certificates:download entrega la contraseña del certificado: dala sólo a quien de verdad descarga.
  • Si una llave se filtra, revócala en tu cuenta y crea otra. La revocación corre de inmediato.
  • No la escribas en logs. Si registras solicitudes, borra la cabecera Authorization.

Errores

Un error responde con un código HTTP 4xx o 5xx y este cuerpo. code es estable y es lo que conviene comparar; message está en español y puede cambiar; param aparece cuando el problema es un campo.

Ejemplo: 400JSON
{
  "error": {
    "type": "invalid_request",
    "code": "months_out_of_range",
    "message": "La vigencia tiene que estar entre 3 y 36 meses.",
    "param": "months"
  }
}

Algunos errores traen datos extra junto a type y code: insufficient_scope trae scope; not_renewable, renewal o pending_renewal.

Tipos

typeHTTPSignifica
invalid_request400, 413, 422La solicitud está mal formada o un dato no es válido. param dice cuál.
authentication401Falta la llave, no existe o está revocada.
permission403La llave existe, pero no tiene el alcance que pide el endpoint.
not_found404El recurso no existe o no es de tu cuenta.
conflict409La operación no se puede hacer en el estado actual del recurso.
rate_limit429Pasaste el límite de solicitudes. Espera lo que diga Retry-After.
api_error500, 502, 503Un problema nuestro. Reintenta con espera creciente.

Códigos

codetypeSignifica
missing_api_keyauthenticationNo mandaste la cabecera Authorization.
invalid_api_keyauthenticationLa llave no existe o está mal copiada.
revoked_api_keyauthenticationLa llave fue revocada. Crea otra en tu cuenta.
insufficient_scopepermissionA la llave le falta un alcance; error.scope dice cuál.
not_foundnot_foundNo existe, o no es de tu cuenta.
invalid_jsoninvalid_requestEl cuerpo no es JSON válido (o no es UTF-8).
months_out_of_rangeinvalid_requestmonths está fuera del rango que se vende (ver /prices).
invalid_rutinvalid_requestUn RUT no es válido (revisa el dígito verificador). param dice cuál.
holder_not_personinvalid_requestEl RUT del titular es de una empresa (50.000.000 o más). El certificado es de una persona; la empresa va en la factura.
invalid_emailinvalid_requestEl correo del titular no es válido.
invalid_nameinvalid_requestMandaste holder.name con más de 64 caracteres. El campo se ignora: no lo mandes.
invalid_documentinvalid_requestdocument no es boleta ni factura.
company_rut_requiredinvalid_requestPediste factura sin company_rut.
company_not_foundinvalid_requestEl SII no tiene una empresa con ese RUT a la cual facturar.
company_without_activityinvalid_requestEl SII no tiene el giro de esa empresa, y sin giro no se puede emitir la factura. Pide boleta, o actualiza el giro en el SII y vuelve a intentarlo. param = company_rut. No se cobra nada.
holder_has_valid_certificateconflictEl titular ya tiene un certificado vigente. Renuévalo cuando se abra la ventana, 30 días antes del vencimiento.
subscription_activeconflictEl titular tiene una suscripción anual activa: su certificado se renueva solo cada año, así que otra compra o una renovación a mano pagaría el mismo año dos veces. Para hacerlo a mano, primero cancela la suscripción. Al renovar, error.subscription dice cuál es.
idempotency_key_invalidinvalid_requestIdempotency-Key no tiene entre 16 y 128 caracteres de [A-Za-z0-9_-].
idempotency_key_reusedconflictUsaste la misma Idempotency-Key con otro cuerpo.
not_renewableconflictNo se puede renovar todavía (error.renewal.opens_at dice desde cuándo) o ya hay una renovación en curso (error.pending_renewal).
not_activeconflictLa operación exige un certificado active (revocar, descargar).
already_revokedconflictEl certificado ya estaba revocado.
invalid_reasoninvalid_requestreason no es uno de los motivos de revocación aceptados.
not_payableconflictLa compra ya no está esperando pago, ya pasó su plazo para pagar (crea otra), o tiene un pago en curso (espera a que se confirme).
invalid_urlinvalid_requestLa URL del webhook no es https, no es pública o no usa el puerto 443.
invalid_eventsinvalid_requestevents está vacío o trae un evento que no existe.
too_many_webhooksconflictYa tienes 5 webhooks, el máximo por cuenta.
webhook_disabledconflictEl webhook está pausado: no recibe entregas, tampoco la prueba. Actívalo con PATCH /webhooks/{id}.
no_subscriptionconflictEl certificado no es de una suscripción anual.
invalid_phoneinvalid_requestholder.phone no es un celular chileno.
not_awaiting_identityconflictEl certificado no está pagado y esperando la verificación de identidad.
identity_unavailableconflictLa verificación de identidad no está disponible para este certificado; lo revisará una persona.
identity_attempts_exhaustedconflictSe usaron los 3 intentos de verificación. Revisaremos los datos y escribiremos al titular.
already_canceledconflictLa suscripción ya estaba cancelada.
payload_too_largeinvalid_requestEl cuerpo pasa de 64 KB (HTTP 413).
invalid_queryinvalid_requestLa query es demasiado larga o trae caracteres que no se pueden reenviar tal cual.
rate_limitedrate_limitPasaste un límite: las 60 solicitudes por minuto de la llave, o las 10 descargas por hora de un certificado. Retry-After dice cuánto esperar.
upstream_unavailableapi_errorEl servicio no respondió (HTTP 502). Reintenta en unos segundos.
internal_errorapi_errorUn error nuestro (HTTP 500). Si se repite, escríbenos con la hora y el endpoint.

Ante un api_error o un 429, reintenta con espera creciente (1 s, 2 s, 4 s…). Ante un 4xx distinto de 429, no reintentes igual: corrige la solicitud.

Límites

Cada llave puede hacer 60 solicitudes por minuto, en ventanas fijas de 60 segundos. Toda respuesta trae cuánto te queda:

CabeceraQué dice
X-RateLimit-LimitSolicitudes por ventana (60).
X-RateLimit-RemainingCuántas te quedan en esta ventana.
X-RateLimit-ResetCuándo empieza la ventana siguiente, en segundos epoch.
Retry-AfterSólo en un 429: cuántos segundos esperar.

Al pasarte, la respuesta es 429 con rate_limited. Espera lo que diga Retry-After antes de volver a intentar. Para seguir el estado de muchos certificados, usa webhooks en vez de consultar cada tanto. Descargar tiene además su propio límite: 10 veces por hora por certificado, también con 429 rate_limited y Retry-After.

Paginación

Las listas (GET /certificates, GET /events, GET /webhooks) vienen del más nuevo al más antiguo, en páginas:

ParámetroQué hace
limitCuántos elementos traer, de 1 a 100. Por omisión, 20.
starting_afterEl id del último elemento que ya tienes: trae los que siguen.
Forma de una listaJSON
{
  "object": "list",
  "data": [
    "…"
  ],
  "has_more": true
}
Recorrer todas las páginasJavaScript
async function todosLosCertificados() {
  const todos = [];
  let despues = null;
  for (;;) {
    const url = new URL('https://hankos.cl/api/v1/certificates');
    url.searchParams.set('limit', '100');
    if (despues) url.searchParams.set('starting_after', despues);

    const res = await fetch(url, {
      headers: { Authorization: `Bearer ${process.env.ASTROBIT_API_KEY}` },
    });
    const pagina = await res.json();
    if (!res.ok) throw new Error(pagina.error.code);

    todos.push(...pagina.data);
    if (!pagina.has_more) return todos;
    despues = pagina.data[pagina.data.length - 1].id;
  }
}

Idempotencia

Comprar (POST /certificates) y renovar (POST /certificates/{id}/renew) crean una compra. Si la conexión se corta, no sabes si alcanzó a crearse. Para reintentar sin crear dos, manda la cabecera Idempotency-Key:

HTTP
Idempotency-Key: pedido-8841-certificado
  • Entre 16 y 128 caracteres de A-Z a-z 0-9 _ -. Si no, 400 idempotency_key_invalid.
  • Misma llave y mismo cuerpo: la misma compra, con 200 en vez de 201. Reintentar es seguro.
  • Misma llave y otro cuerpo: 409 idempotency_key_reused.
  • Si la compra de esa llave terminó sin certificado (venció sin pagarse, se rechazó o se devolvió el pago), la misma llave crea una compra nueva, con su enlace de pago.
  • Las llaves son por cuenta. Derívala de tu propio pedido (por ejemplo, su número), no la generes al azar en cada intento.

El objeto certificate

Un certificado nace con la compra y la sigue hasta vencer: su id es la referencia de la compra y no cambia. Una renovación es una compra nueva, con su propio id, que apunta al anterior en renews.

Un certificado vigenteJSON
{
  "id": "cert-8f2c1a9b0d3e4f5a6b7c8d9e",
  "object": "certificate",
  "status": "active",
  "months": 12,
  "price": {
    "amount": 8990,
    "currency": "CLP",
    "tax_included": true
  },
  "document": "boleta",
  "holder": {
    "rut": "12345678-5",
    "name": "Juana Pérez",
    "email": "compras@empresa.cl",
    "phone": "+56912345678"
  },
  "buyer": {
    "email": "compras@empresa.cl",
    "rut": null,
    "name": null
  },
  "renews": null,
  "renewed_by": null,
  "created_at": "2026-09-27T14:03:11Z",
  "paid_at": "2026-09-27T14:05:40Z",
  "issued_at": "2026-09-27T16:20:02Z",
  "expires_at": "2027-09-27T16:20:02Z",
  "revoked_at": null,
  "renewal": {
    "opens_at": "2027-08-28T16:20:02Z",
    "open": false
  },
  "identity_url": null,
  "payment_url": null,
  "cancel_reason": null,
  "subscription": null
}

Campos

CampoTipoDescripción
idstringLa referencia de la compra: cert- y 24 caracteres hexadecimales. Es el mismo id desde la compra hasta el vencimiento.
objectstringSiempre certificate.
statusstringEl estado. Ver la tabla de estados.
monthsintegerLa vigencia comprada, en meses.
priceobjectamount (CLP, entero), currency (CLP) y tax_included (true: IVA incluido).
documentstringboleta (boleta electrónica, tipo 39) o factura (factura electrónica, tipo 33).
holderobjectEl titular: rut (sin puntos, con guion: 12345678-5), name (el de su cédula; null hasta que verifica su identidad), email (donde le llega el certificado) y phone (celular, +569…).
buyerobjectQuien compró: email (el de la cuenta), y rut y name de la empresa si se pidió factura; si no, null.
renewsstring | nullSi esta compra es una renovación, el id del certificado que renueva.
renewed_bystring | nullEl id de la compra más nueva que renueva a éste, en cualquier estado: también una en curso o una canceled. Antes de renovar otra vez, mira su status.
created_atstringCuándo se creó la compra.
paid_atstring | nullCuándo se confirmó el pago.
issued_atstring | nullCuándo se emitió el certificado.
expires_atstring | nullCuándo vence. null hasta que se emite.
revoked_atstring | nullCuándo se revocó.
renewalobject | nullopens_at = expires_at menos 30 días; open = true si ya se puede renovar. null mientras no esté emitido.
identity_urlstring | nullSólo en awaiting_identity: la página donde el titular verifica su identidad.
payment_urlstring | nullSólo en las respuestas de comprar, renovar y pedir enlace de pago: el enlace para pagar.
cancel_reasonstring | nullSólo en canceled: por qué. payment_expired (no se pagó a tiempo; no se cobró nada), refunded (se devolvió el pago), identity_rejected (no se pudo verificar la identidad del titular y se devolvió el pago) o refund_pending (se cobró y la devolución está en curso).
subscriptionobject | nullLa suscripción anual a la que pertenece el certificado, o null si no es de una suscripción. Una suscripción nace cuando se paga el primer año: mientras tanto (y mientras se confirma el pago) viene con status: "pending", id: null y el resto vacío. Ver sus campos abajo.
subscription.idstring | nullEl id de la suscripción: sub_…. Es el mismo en todos los certificados que emite. null mientras está pending.
subscription.statusstringpending (la compra que la crea todavía no se paga, o se está confirmando), active (se renueva sola) o canceled (no habrá más cobros).
subscription.card_on_filebooleantrue si hay una tarjeta inscrita para el cobro anual.
subscription.next_charge_atstring | nullCuándo se cobra la próxima renovación: 21 días antes del vencimiento. null hasta que el certificado se emite, o si está cancelada.
subscription.next_amountinteger | nullCuánto se cobrará, en CLP con IVA incluido.
subscription.enroll_urlstring | nullDónde inscribir la tarjeta (Webpay Oneclick), mientras no haya una inscrita.

Estados

statusSignificaQué hacer
pending_paymentCreado; falta pagar. Dura 24 horas.Manda a pagar a payment_url. Si se perdió, pide otro con POST /certificates/{id}/payment-link.
awaiting_identityPagado. El titular tiene que verificar su identidad con su cédula y una selfie. Si calza, pasa directo a `active`.Envíale al titular el identity_url, o pide una sesión con POST /certificates/{id}/identity-session. Llega también en el evento certificate.paid.
verifyingLa verificación automática no alcanzó (algo no calzó o se agotaron los intentos) y una persona revisa los datos.Nada. Espera certificate.issued o certificate.renewed.
under_reviewAlgo del pago o de la emisión lo revisa una persona.Nada. Te escribimos si hace falta algo.
activeEmitido y vigente.Descárgalo con POST /certificates/{id}/download. Renueva desde renewal.opens_at.
expiredVenció.Renuévalo (/renew) o compra uno nuevo.
revokedRevocado. No firma más.Compra uno nuevo si el titular lo sigue necesitando.
canceledNo se pagó a tiempo, se devolvió el pago o no se pudo verificar la identidad.Crea otra compra si todavía hace falta.

Ciclo de vida

Del pedido al vencimiento, con el estado en que queda el certificado y el evento que te avisa.

  1. Comprarpending_payment

    POST /certificates crea la compra y devuelve payment_url. Hay 24 horas para pagar; si no, pasa a canceled y llega certificate.canceled.

  2. Pagarawaiting_identity

    Quien paga abre payment_url y paga con Webpay. Llega certificate.paid, con identity_url.

  3. El titular verifica su identidadactive · verifying

    El titular abre identity_url (o la página de POST /certificates/{id}/identity-session) y verifica su identidad, en un minuto. No necesita cuenta. Si calza, el certificado se emite y pasa directo a active; si no, puede intentarlo de nuevo (hasta 3 intentos) y, si aun así no calza, queda en verifying y lo revisa una persona. Le avisamos por correo, pero conviene que tu sistema también le haga llegar el enlace.

  4. Emitidoactive

    Casi siempre, minutos después del pago. Llega certificate.issued (o certificate.renewed) y el titular recibe por correo el enlace para descargarlo; tu sistema también puede descargarlo con POST /certificates/{id}/download.

  5. Por venceractive

    30 días antes del vencimiento se abre la renovación (renewal.open) y llega certificate.expiring; otra vez a los 7 días.

  6. Renovarpending_payment

    POST /certificates/{id}/renew crea la compra nueva y el ciclo vuelve al paso 2: pagar y verificar identidad otra vez. El certificado anterior sigue vigente hasta su fecha.

  7. Vence o se revocaexpired · revoked

    Al vencer llega certificate.expired. Si la clave se filtró o el titular dejó la empresa, POST /certificates/{id}/revoke lo revoca de inmediato y llega certificate.revoked.

Desvíos: si el pago o la emisión necesitan una revisión, el certificado queda en under_review hasta que la resolvamos. Si la identidad no se puede confirmar, o se devuelve el pago, pasa a canceled. Con la suscripción anual, el paso 6 lo hace la suscripción: cobra la renovación sola cada año.

La verificación de identidad y tus datos

La verificación la hace Didit, por encargo de Astrobit: el titular toma una foto de su cédula y una selfie, y Didit comprueba que la persona está presente y que su rostro corresponde al de la cédula. Didit también recibe el correo y el celular del titular. Antes de empezar, el titular da su consentimiento en nuestra página. Astrobit no guarda las imágenes: guarda el resultado, el nombre, el número de la cédula y los puntajes de la verificación, hasta 6 años desde la emisión (ver la política de privacidad). Astrobit sólo emite con una verificación de menos de 30 días, así que cada renovación la pide de nuevo.

Precios

Ver los precios

GET/pricesalcance: certificates:read

Las vigencias destacadas (options) y la tabla de la vigencia personalizada (custom): un precio por cada mes entre min_months y max_months. En CLP, IVA incluido. Es el precio que se cobra al comprar. Si hay una oferta vigente, promo la describe; si no, es null.

curlbash
curl "https://hankos.cl/api/v1/prices" \
  -H "Authorization: Bearer $ASTROBIT_API_KEY"
fetchJavaScript
const res = await fetch('https://hankos.cl/api/v1/prices', {
  headers: {
    Authorization: `Bearer ${process.env.ASTROBIT_API_KEY}`,
  },
});
const datos = await res.json();
if (!res.ok) throw new Error(`${datos.error.code}: ${datos.error.message}`);
Respuesta: 200JSON
{
  "object": "price_list",
  "currency": "CLP",
  "tax_included": true,
  "options": [
    {
      "months": 6,
      "amount": 4490
    },
    {
      "months": 12,
      "amount": 8990
    },
    {
      "months": 24,
      "amount": 16490
    },
    {
      "months": 36,
      "amount": 16490,
      "regular_amount": 22390
    }
  ],
  "custom": {
    "min_months": 3,
    "max_months": 36,
    "prices": [
      {
        "months": 3,
        "amount": 3990
      },
      {
        "months": 4,
        "amount": 3990
      },
      {
        "months": 30,
        "amount": 16490,
        "regular_amount": 19390
      }
    ]
  },
  "promo": {
    "label": "3 años al precio de 2",
    "months": 36,
    "amount": 16490,
    "regular_amount": 22390,
    "ends_at": "2027-01-01T02:59:59Z"
  },
  "subscription": {
    "months": 12,
    "prices": [
      {
        "year": 1,
        "amount": 8990
      },
      {
        "year": 2,
        "amount": 7590
      },
      {
        "year": 3,
        "amount": 5990
      }
    ]
  }
}

regular_amount es el precio sin descuento de esa vigencia: si es mayor que amount, la vigencia está en oferta y amount es lo que se cobra.

promo trae label, los months en oferta, amount, regular_amount y ends_at: la fecha real en que termina (ISO 8601, UTC). Desde ese momento se cobra el precio normal; no cachees amount más allá de ends_at.

Los montos del ejemplo son ilustrativos, y custom.prices va abreviado: la respuesta trae un precio por cada mes del rango.

Certificados

Comprar un certificado

POST/certificatesalcance: certificates:write

Crea la compra en pending_payment y devuelve el enlace para pagarla. El comprador es el correo de tu cuenta. Después del pago, el titular tiene que verificar su identidad.

En el cuerpo (JSON)

NombreTipoDescripción
monthsintegerobligatorioVigencia en meses, dentro de custom.min_months..custom.max_months de /prices. Con subscription: true es opcional y siempre 12 (otro valor: 400 months_out_of_range).
documentstringopcionalboleta (por omisión) o factura.
company_rutstringopcionalRUT de la empresa a la que se factura. Obligatorio con factura; los datos se toman del SII.
holder.rutstringobligatorioRUT personal del titular, con o sin puntos. Una persona natural: un RUT de empresa se rechaza con holder_not_person.
holder.phonestringopcionalCelular chileno del titular, en cualquier formato (9 1234 5678, +56 9 1234 5678). Si no es un celular válido, 400 invalid_phone.
holder.emailstringopcionalDonde le llegan al titular el aviso para verificar su identidad y el certificado. Por omisión, el correo de tu cuenta.
holder.namestringopcionalSe ignora: el nombre del certificado sale sólo de la cédula del titular, cuando verifica su identidad. No lo mandes; uno de más de 64 caracteres todavía se rechaza con invalid_name.
subscriptionbooleanopcionaltrue para la suscripción anual: la vigencia es de 12 meses y se renueva sola cada año. Al mandarlo declaras que el comprador aceptó la renovación y el cobro de cada año hasta que la cancele. Ver Suscripciones.

Acepta Idempotency-Key.

curlbash
curl -X POST "https://hankos.cl/api/v1/certificates" \
  -H "Authorization: Bearer $ASTROBIT_API_KEY" \
  -H "Idempotency-Key: pedido-8841-certificado" \
  -H "Content-Type: application/json" \
  -d '{
  "months": 12,
  "document": "boleta",
  "holder": {
    "rut": "12.345.678-5",
    "phone": "9 1234 5678"
  }
}'
fetchJavaScript
const res = await fetch('https://hankos.cl/api/v1/certificates', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.ASTROBIT_API_KEY}`,
    'Idempotency-Key': 'pedido-8841-certificado',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    "months": 12,
    "document": "boleta",
    "holder": {
      "rut": "12.345.678-5",
      "phone": "9 1234 5678"
    }
  }),
});
const datos = await res.json();
if (!res.ok) throw new Error(`${datos.error.code}: ${datos.error.message}`);
Respuesta: 201JSON
{
  "certificate": {
    "id": "cert-8f2c1a9b0d3e4f5a6b7c8d9e",
    "object": "certificate",
    "status": "pending_payment",
    "months": 12,
    "price": {
      "amount": 8990,
      "currency": "CLP",
      "tax_included": true
    },
    "document": "boleta",
    "holder": {
      "rut": "12345678-5",
      "name": null,
      "email": "compras@empresa.cl",
      "phone": "+56912345678"
    },
    "buyer": {
      "email": "compras@empresa.cl",
      "rut": null,
      "name": null
    },
    "renews": null,
    "renewed_by": null,
    "created_at": "2026-09-27T14:03:11Z",
    "paid_at": null,
    "issued_at": null,
    "expires_at": null,
    "revoked_at": null,
    "renewal": null,
    "identity_url": null,
    "payment_url": "https://hankos.cl/pagar/cert-8f2c1a9b0d3e4f5a6b7c8d9e?e=1790604191&s=13fa3f91087b531126059785ac97193a59230286c59c9b321416215394caa48c",
    "cancel_reason": null,
    "subscription": null
  },
  "payment_url": "https://hankos.cl/pagar/cert-8f2c1a9b0d3e4f5a6b7c8d9e?e=1790604191&s=13fa3f91087b531126059785ac97193a59230286c59c9b321416215394caa48c"
}

Errores propios: months_out_of_range, invalid_rut, holder_not_person, invalid_phone, invalid_email, invalid_name, invalid_document, company_rut_required, company_not_found, company_without_activity, holder_has_valid_certificate, subscription_active, idempotency_key_invalid, idempotency_key_reused.

Con la misma Idempotency-Key y el mismo cuerpo devuelve la misma compra, con 200 en vez de 201.

La compra dura 24 horas sin pagar; después pasa a canceled.

Con subscription: true, el primer año se paga igual, en payment_url. Hasta que se paga, certificate.subscription viene con status: "pending" e id: null: la suscripción nace con el pago, y su id llega con certificate.paid.

Listar certificados

GET/certificatesalcance: certificates:read

Los certificados donde el correo de tu cuenta es el del comprador o el del titular, el más nuevo primero.

En la query

NombreTipoDescripción
statusstringopcionalSólo los de ese estado.
holder_rutstringopcionalSólo los de ese titular.
limitintegeropcionalCuántos traer, de 1 a 100. Por omisión, 20.
starting_afterstringopcionalEl id del último elemento de la página anterior.
curlbash
curl "https://hankos.cl/api/v1/certificates?status=active&limit=20" \
  -H "Authorization: Bearer $ASTROBIT_API_KEY"
fetchJavaScript
const res = await fetch('https://hankos.cl/api/v1/certificates?status=active&limit=20', {
  headers: {
    Authorization: `Bearer ${process.env.ASTROBIT_API_KEY}`,
  },
});
const datos = await res.json();
if (!res.ok) throw new Error(`${datos.error.code}: ${datos.error.message}`);
Respuesta: 200JSON
{
  "object": "list",
  "data": [
    {
      "id": "cert-8f2c1a9b0d3e4f5a6b7c8d9e",
      "object": "certificate",
      "status": "active",
      "months": 12,
      "price": {
        "amount": 8990,
        "currency": "CLP",
        "tax_included": true
      },
      "document": "boleta",
      "holder": {
        "rut": "12345678-5",
        "name": "Juana Pérez",
        "email": "compras@empresa.cl",
        "phone": "+56912345678"
      },
      "buyer": {
        "email": "compras@empresa.cl",
        "rut": null,
        "name": null
      },
      "renews": null,
      "renewed_by": null,
      "created_at": "2026-09-27T14:03:11Z",
      "paid_at": "2026-09-27T14:05:40Z",
      "issued_at": "2026-09-27T16:20:02Z",
      "expires_at": "2027-09-27T16:20:02Z",
      "revoked_at": null,
      "renewal": {
        "opens_at": "2027-08-28T16:20:02Z",
        "open": false
      },
      "identity_url": null,
      "payment_url": null,
      "cancel_reason": null,
      "subscription": null
    }
  ],
  "has_more": false
}

Ver un certificado

GET/certificates/{id}alcance: certificates:read

El estado actual de un certificado. Es lo que conviene consultar cuando recibes un evento, en vez de confiar sólo en el cuerpo del evento.

En la ruta

NombreTipoDescripción
idstringobligatorioEl id del certificado (cert-…).
curlbash
curl "https://hankos.cl/api/v1/certificates/cert-8f2c1a9b0d3e4f5a6b7c8d9e" \
  -H "Authorization: Bearer $ASTROBIT_API_KEY"
fetchJavaScript
const res = await fetch('https://hankos.cl/api/v1/certificates/cert-8f2c1a9b0d3e4f5a6b7c8d9e', {
  headers: {
    Authorization: `Bearer ${process.env.ASTROBIT_API_KEY}`,
  },
});
const datos = await res.json();
if (!res.ok) throw new Error(`${datos.error.code}: ${datos.error.message}`);
Respuesta: 200JSON
{
  "id": "cert-8f2c1a9b0d3e4f5a6b7c8d9e",
  "object": "certificate",
  "status": "active",
  "months": 12,
  "price": {
    "amount": 8990,
    "currency": "CLP",
    "tax_included": true
  },
  "document": "boleta",
  "holder": {
    "rut": "12345678-5",
    "name": "Juana Pérez",
    "email": "compras@empresa.cl",
    "phone": "+56912345678"
  },
  "buyer": {
    "email": "compras@empresa.cl",
    "rut": null,
    "name": null
  },
  "renews": null,
  "renewed_by": null,
  "created_at": "2026-09-27T14:03:11Z",
  "paid_at": "2026-09-27T14:05:40Z",
  "issued_at": "2026-09-27T16:20:02Z",
  "expires_at": "2027-09-27T16:20:02Z",
  "revoked_at": null,
  "renewal": {
    "opens_at": "2027-08-28T16:20:02Z",
    "open": false
  },
  "identity_url": null,
  "payment_url": null,
  "cancel_reason": null,
  "subscription": null
}

Errores propios: not_found.

Renovar

POST/certificates/{id}/renewalcance: certificates:write

Crea una compra nueva que renueva al certificado (renews = su id), en pending_payment, con su enlace de pago. Sirve para un certificado active o expired de tu cuenta. El comprador de la renovación es tu cuenta. Después del pago, el titular vuelve a verificar su identidad.

En la ruta

NombreTipoDescripción
idstringobligatorioEl id del certificado (cert-…).

En el cuerpo (JSON)

NombreTipoDescripción
monthsintegeropcionalPor omisión, los del original.
documentstringopcionalPor omisión, el del original.
company_rutstringopcionalPor omisión, el del original si fue con factura.

Acepta Idempotency-Key.

curlbash
curl -X POST "https://hankos.cl/api/v1/certificates/cert-8f2c1a9b0d3e4f5a6b7c8d9e/renew" \
  -H "Authorization: Bearer $ASTROBIT_API_KEY" \
  -H "Idempotency-Key: pedido-8841-certificado" \
  -H "Content-Type: application/json" \
  -d '{
  "months": 24
}'
fetchJavaScript
const res = await fetch('https://hankos.cl/api/v1/certificates/cert-8f2c1a9b0d3e4f5a6b7c8d9e/renew', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.ASTROBIT_API_KEY}`,
    'Idempotency-Key': 'pedido-8841-certificado',
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    "months": 24
  }),
});
const datos = await res.json();
if (!res.ok) throw new Error(`${datos.error.code}: ${datos.error.message}`);
Respuesta: 201JSON
{
  "certificate": {
    "id": "cert-1b7e0c2d4a6f8e9c3b5d7a10",
    "object": "certificate",
    "status": "pending_payment",
    "months": 24,
    "price": {
      "amount": 16490,
      "currency": "CLP",
      "tax_included": true
    },
    "document": "boleta",
    "holder": {
      "rut": "12345678-5",
      "name": null,
      "email": "compras@empresa.cl",
      "phone": "+56912345678"
    },
    "buyer": {
      "email": "compras@empresa.cl",
      "rut": null,
      "name": null
    },
    "renews": "cert-8f2c1a9b0d3e4f5a6b7c8d9e",
    "renewed_by": null,
    "created_at": "2027-09-01T10:12:45Z",
    "paid_at": null,
    "issued_at": null,
    "expires_at": null,
    "revoked_at": null,
    "renewal": null,
    "identity_url": null,
    "payment_url": "https://hankos.cl/pagar/cert-1b7e0c2d4a6f8e9c3b5d7a10?e=1790604191&s=13fa3f91087b531126059785ac97193a59230286c59c9b321416215394caa48c",
    "cancel_reason": null,
    "subscription": null
  },
  "payment_url": "https://hankos.cl/pagar/cert-1b7e0c2d4a6f8e9c3b5d7a10?e=1790604191&s=13fa3f91087b531126059785ac97193a59230286c59c9b321416215394caa48c"
}

Errores propios: not_renewable, subscription_active, not_found, months_out_of_range, company_rut_required, company_without_activity, idempotency_key_invalid, idempotency_key_reused.

Si la ventana todavía no abre, 409 not_renewable con error.renewal.opens_at. Si ya hay una renovación en curso, 409 not_renewable con error.pending_renewal (el id de esa compra): págala en vez de crear otra.

Pedir un enlace de pago

POST/certificates/{id}/payment-linkalcance: certificates:write

Un enlace nuevo para pagar una compra en pending_payment, si perdiste el payment_url. Si ya pasó el plazo para pagar, o hay un pago en curso, responde 409 not_payable: en el primer caso crea otra compra; en el segundo, espera a que el pago se confirme.

En la ruta

NombreTipoDescripción
idstringobligatorioEl id del certificado (cert-…).
curlbash
curl -X POST "https://hankos.cl/api/v1/certificates/cert-8f2c1a9b0d3e4f5a6b7c8d9e/payment-link" \
  -H "Authorization: Bearer $ASTROBIT_API_KEY"
fetchJavaScript
const res = await fetch('https://hankos.cl/api/v1/certificates/cert-8f2c1a9b0d3e4f5a6b7c8d9e/payment-link', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.ASTROBIT_API_KEY}`,
  },
});
const datos = await res.json();
if (!res.ok) throw new Error(`${datos.error.code}: ${datos.error.message}`);
Respuesta: 200JSON
{
  "payment_url": "https://hankos.cl/pagar/cert-8f2c1a9b0d3e4f5a6b7c8d9e?e=1790604191&s=13fa3f91087b531126059785ac97193a59230286c59c9b321416215394caa48c",
  "expires_at": "2026-09-28T14:03:11Z"
}

Errores propios: not_payable, not_found.

Abrir la verificación de identidad

POST/certificates/{id}/identity-sessionalcance: certificates:write

Una sesión de verificación de identidad (Didit) para un certificado pagado y esperando identidad: la página donde el titular saca una foto de su cédula y una selfie. Si ya hay una sesión abierta, la reusa por una hora. Úsala si quieres mandar al titular directo a la verificación; si no, identity_url hace lo mismo desde nuestra página.

En la ruta

NombreTipoDescripción
idstringobligatorioEl id del certificado (cert-…).
curlbash
curl -X POST "https://hankos.cl/api/v1/certificates/cert-8f2c1a9b0d3e4f5a6b7c8d9e/identity-session" \
  -H "Authorization: Bearer $ASTROBIT_API_KEY"
fetchJavaScript
const res = await fetch('https://hankos.cl/api/v1/certificates/cert-8f2c1a9b0d3e4f5a6b7c8d9e/identity-session', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.ASTROBIT_API_KEY}`,
  },
});
const datos = await res.json();
if (!res.ok) throw new Error(`${datos.error.code}: ${datos.error.message}`);
Respuesta: 200JSON
{
  "object": "identity_session",
  "url": "https://verify.didit.me/session/…",
  "certificate_id": "cert-8f2c1a9b0d3e4f5a6b7c8d9e"
}

Errores propios: not_awaiting_identity, identity_unavailable, identity_attempts_exhausted, not_found.

El enlace es para el titular: mándaselo sólo a él. Al terminar, Didit lo trae de vuelta a nuestra página, que le muestra el resultado.

El resultado llega como evento: certificate.issued (o certificate.renewed) si se verificó; si lo revisa una persona, el certificado queda en verifying.

Revocar

POST/certificates/{id}/revokealcance: certificates:write

Revoca un certificado active. Es irreversible y no tiene reembolso: el certificado deja de firmar. Si es el certificado vigente de una suscripción anual, la suscripción también termina: no habrá más cobros. Devuelve el certificado actualizado.

En la ruta

NombreTipoDescripción
idstringobligatorioEl id del certificado (cert-…).

En el cuerpo (JSON)

NombreTipoDescripción
reasonstringopcionalunspecified (por omisión), key_compromise (la clave se filtró), superseded (lo reemplaza otro), cessation_of_operation (la empresa dejó de operar) o affiliation_changed (el titular cambió de empresa).
curlbash
curl -X POST "https://hankos.cl/api/v1/certificates/cert-8f2c1a9b0d3e4f5a6b7c8d9e/revoke" \
  -H "Authorization: Bearer $ASTROBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "reason": "key_compromise"
}'
fetchJavaScript
const res = await fetch('https://hankos.cl/api/v1/certificates/cert-8f2c1a9b0d3e4f5a6b7c8d9e/revoke', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.ASTROBIT_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    "reason": "key_compromise"
  }),
});
const datos = await res.json();
if (!res.ok) throw new Error(`${datos.error.code}: ${datos.error.message}`);
Respuesta: 200JSON
{
  "id": "cert-8f2c1a9b0d3e4f5a6b7c8d9e",
  "object": "certificate",
  "status": "revoked",
  "months": 12,
  "price": {
    "amount": 8990,
    "currency": "CLP",
    "tax_included": true
  },
  "document": "boleta",
  "holder": {
    "rut": "12345678-5",
    "name": "Juana Pérez",
    "email": "compras@empresa.cl",
    "phone": "+56912345678"
  },
  "buyer": {
    "email": "compras@empresa.cl",
    "rut": null,
    "name": null
  },
  "renews": null,
  "renewed_by": null,
  "created_at": "2026-09-27T14:03:11Z",
  "paid_at": "2026-09-27T14:05:40Z",
  "issued_at": "2026-09-27T16:20:02Z",
  "expires_at": "2027-09-27T16:20:02Z",
  "revoked_at": "2027-03-02T12:44:10Z",
  "renewal": {
    "opens_at": "2027-08-28T16:20:02Z",
    "open": false
  },
  "identity_url": null,
  "payment_url": null,
  "cancel_reason": null,
  "subscription": null
}

Errores propios: not_active, already_revoked, invalid_reason, not_found.

Cancelar la suscripción

POST/certificates/{id}/subscription/cancelalcance: certificates:write

Cancela la suscripción anual del certificado: no habrá más cobros ni renovaciones automáticas. El certificado vigente sigue funcionando hasta su vencimiento y el año en curso no se reembolsa. Devuelve el certificado, con subscription.status = canceled.

En la ruta

NombreTipoDescripción
idstringobligatorioEl id de cualquier certificado de la suscripción (cert-…).
curlbash
curl -X POST "https://hankos.cl/api/v1/certificates/cert-8f2c1a9b0d3e4f5a6b7c8d9e/subscription/cancel" \
  -H "Authorization: Bearer $ASTROBIT_API_KEY"
fetchJavaScript
const res = await fetch('https://hankos.cl/api/v1/certificates/cert-8f2c1a9b0d3e4f5a6b7c8d9e/subscription/cancel', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.ASTROBIT_API_KEY}`,
  },
});
const datos = await res.json();
if (!res.ok) throw new Error(`${datos.error.code}: ${datos.error.message}`);
Respuesta: 200JSON
{
  "id": "cert-8f2c1a9b0d3e4f5a6b7c8d9e",
  "object": "certificate",
  "status": "active",
  "months": 12,
  "price": {
    "amount": 8990,
    "currency": "CLP",
    "tax_included": true
  },
  "document": "boleta",
  "holder": {
    "rut": "12345678-5",
    "name": "Juana Pérez",
    "email": "compras@empresa.cl",
    "phone": "+56912345678"
  },
  "buyer": {
    "email": "compras@empresa.cl",
    "rut": null,
    "name": null
  },
  "renews": null,
  "renewed_by": null,
  "created_at": "2026-09-27T14:03:11Z",
  "paid_at": "2026-09-27T14:05:40Z",
  "issued_at": "2026-09-27T16:20:02Z",
  "expires_at": "2027-09-27T16:20:02Z",
  "revoked_at": null,
  "renewal": {
    "opens_at": "2027-08-28T16:20:02Z",
    "open": false
  },
  "identity_url": null,
  "payment_url": null,
  "cancel_reason": null,
  "subscription": {
    "id": "sub_7d1e9a4c2b6f8e0a3c5d7b92",
    "status": "canceled",
    "card_on_file": true,
    "next_charge_at": null,
    "next_amount": null,
    "enroll_url": null
  }
}

Errores propios: no_subscription, already_canceled, not_found.

Llega el evento subscription.canceled.

Descargar

POST/certificates/{id}/downloadalcance: certificates:download

Un enlace para descargar el .pfx y la contraseña que lo abre. Sólo para un certificado active. El enlace sirve una vez y vence: pide uno nuevo cada vez que lo necesites.

En la ruta

NombreTipoDescripción
idstringobligatorioEl id del certificado (cert-…).
curlbash
curl -X POST "https://hankos.cl/api/v1/certificates/cert-8f2c1a9b0d3e4f5a6b7c8d9e/download" \
  -H "Authorization: Bearer $ASTROBIT_API_KEY"
fetchJavaScript
const res = await fetch('https://hankos.cl/api/v1/certificates/cert-8f2c1a9b0d3e4f5a6b7c8d9e/download', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.ASTROBIT_API_KEY}`,
  },
});
const datos = await res.json();
if (!res.ok) throw new Error(`${datos.error.code}: ${datos.error.message}`);
Respuesta: 200JSON
{
  "object": "certificate_download",
  "download_url": "https://dte.astrobit.cl/d/9b1c6f0e4a2d7c85e3f1a0b4",
  "download_expires_at": "2026-09-27T17:20:02Z",
  "password": "r7T-mq4Z-8kdW"
}

Errores propios: not_active, rate_limited, not_found.

Hasta 10 descargas por hora por certificado; al pasarte, 429 rate_limited con Retry-After.

La respuesta trae la contraseña del certificado: no la guardes en logs. Una llave con este alcance merece el mismo cuidado que el archivo.

Usa download_url tal como viene: su forma puede cambiar.

Eventos

Listar eventos

GET/eventsalcance: certificates:read

Los eventos de tu cuenta de los últimos 30 días, el más nuevo primero. Son los mismos que llegan a tus webhooks: sirve para recuperar los que tu servidor no alcanzó a procesar.

En la query

NombreTipoDescripción
typestringopcionalSólo los de ese tipo.
limitintegeropcionalCuántos traer, de 1 a 100. Por omisión, 20.
starting_afterstringopcionalEl id del último elemento de la página anterior.
curlbash
curl "https://hankos.cl/api/v1/events?type=certificate.expiring&limit=20" \
  -H "Authorization: Bearer $ASTROBIT_API_KEY"
fetchJavaScript
const res = await fetch('https://hankos.cl/api/v1/events?type=certificate.expiring&limit=20', {
  headers: {
    Authorization: `Bearer ${process.env.ASTROBIT_API_KEY}`,
  },
});
const datos = await res.json();
if (!res.ok) throw new Error(`${datos.error.code}: ${datos.error.message}`);
Respuesta: 200JSON
{
  "object": "list",
  "data": [
    {
      "id": "evt_5c2e8a1f0b9d7c3e6a4f2b18",
      "object": "event",
      "type": "certificate.expiring",
      "created_at": "2027-08-28T16:20:05Z",
      "data": {
        "certificate": {
          "id": "cert-8f2c1a9b0d3e4f5a6b7c8d9e",
          "object": "certificate",
          "status": "active",
          "months": 12,
          "price": {
            "amount": 8990,
            "currency": "CLP",
            "tax_included": true
          },
          "document": "boleta",
          "holder": {
            "rut": "12345678-5",
            "name": "Juana Pérez",
            "email": "compras@empresa.cl",
            "phone": "+56912345678"
          },
          "buyer": {
            "email": "compras@empresa.cl",
            "rut": null,
            "name": null
          },
          "renews": null,
          "renewed_by": null,
          "created_at": "2026-09-27T14:03:11Z",
          "paid_at": "2026-09-27T14:05:40Z",
          "issued_at": "2026-09-27T16:20:02Z",
          "expires_at": "2027-09-27T16:20:02Z",
          "revoked_at": null,
          "renewal": {
            "opens_at": "2027-08-28T16:20:02Z",
            "open": true
          },
          "identity_url": null,
          "payment_url": null,
          "cancel_reason": null,
          "subscription": null
        },
        "days_left": 30
      }
    }
  ],
  "has_more": false
}

Ver un evento

GET/events/{id}alcance: certificates:read

Un evento por su id (evt_…), el mismo que llega en la cabecera Astrobit-Event-Id.

En la ruta

NombreTipoDescripción
idstringobligatorioEl id del evento (evt_…).
curlbash
curl "https://hankos.cl/api/v1/events/evt_5c2e8a1f0b9d7c3e6a4f2b18" \
  -H "Authorization: Bearer $ASTROBIT_API_KEY"
fetchJavaScript
const res = await fetch('https://hankos.cl/api/v1/events/evt_5c2e8a1f0b9d7c3e6a4f2b18', {
  headers: {
    Authorization: `Bearer ${process.env.ASTROBIT_API_KEY}`,
  },
});
const datos = await res.json();
if (!res.ok) throw new Error(`${datos.error.code}: ${datos.error.message}`);
Respuesta: 200JSON
{
  "id": "evt_5c2e8a1f0b9d7c3e6a4f2b18",
  "object": "event",
  "type": "certificate.expiring",
  "created_at": "2027-08-28T16:20:05Z",
  "data": {
    "certificate": {
      "id": "cert-8f2c1a9b0d3e4f5a6b7c8d9e",
      "object": "certificate",
      "status": "active",
      "months": 12,
      "price": {
        "amount": 8990,
        "currency": "CLP",
        "tax_included": true
      },
      "document": "boleta",
      "holder": {
        "rut": "12345678-5",
        "name": "Juana Pérez",
        "email": "compras@empresa.cl",
        "phone": "+56912345678"
      },
      "buyer": {
        "email": "compras@empresa.cl",
        "rut": null,
        "name": null
      },
      "renews": null,
      "renewed_by": null,
      "created_at": "2026-09-27T14:03:11Z",
      "paid_at": "2026-09-27T14:05:40Z",
      "issued_at": "2026-09-27T16:20:02Z",
      "expires_at": "2027-09-27T16:20:02Z",
      "revoked_at": null,
      "renewal": {
        "opens_at": "2027-08-28T16:20:02Z",
        "open": true
      },
      "identity_url": null,
      "payment_url": null,
      "cancel_reason": null,
      "subscription": null
    },
    "days_left": 30
  }
}

Errores propios: not_found.

Webhooks

Listar webhooks

GET/webhooksalcance: webhooks:manage

Los webhooks de tu cuenta (hasta 5). Nunca traen el secreto.

curlbash
curl "https://hankos.cl/api/v1/webhooks" \
  -H "Authorization: Bearer $ASTROBIT_API_KEY"
fetchJavaScript
const res = await fetch('https://hankos.cl/api/v1/webhooks', {
  headers: {
    Authorization: `Bearer ${process.env.ASTROBIT_API_KEY}`,
  },
});
const datos = await res.json();
if (!res.ok) throw new Error(`${datos.error.code}: ${datos.error.message}`);
Respuesta: 200JSON
{
  "object": "list",
  "data": [
    {
      "id": "we_3a9f0c1d2e4b5a6c7d8e9f01",
      "object": "webhook_endpoint",
      "url": "https://tu-servidor.cl/webhooks/astrobit",
      "events": [
        "certificate.paid",
        "certificate.issued",
        "certificate.expiring"
      ],
      "enabled": true,
      "created_at": "2026-09-27T15:00:00Z",
      "updated_at": "2026-09-27T15:00:00Z"
    }
  ],
  "has_more": false
}

Crear un webhook

POST/webhooksalcance: webhooks:manage

Registra una URL para recibir eventos. El secreto de firma lo generamos nosotros y viene en secret una sola vez: guárdalo. Los eventos llevan los datos del certificado, así que la llave necesita también certificates:read (si no, 403 insufficient_scope).

En el cuerpo (JSON)

NombreTipoDescripción
urlstringobligatoriohttps, en un host público y en el puerto 443. No se aceptan IP privadas ni localhost.
eventsstring[]obligatorioLos tipos de evento que quieres recibir, o ["*"] para todos (incluidos los que agreguemos).
curlbash
curl -X POST "https://hankos.cl/api/v1/webhooks" \
  -H "Authorization: Bearer $ASTROBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "url": "https://tu-servidor.cl/webhooks/astrobit",
  "events": [
    "certificate.paid",
    "certificate.issued",
    "certificate.expiring"
  ]
}'
fetchJavaScript
const res = await fetch('https://hankos.cl/api/v1/webhooks', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.ASTROBIT_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    "url": "https://tu-servidor.cl/webhooks/astrobit",
    "events": [
      "certificate.paid",
      "certificate.issued",
      "certificate.expiring"
    ]
  }),
});
const datos = await res.json();
if (!res.ok) throw new Error(`${datos.error.code}: ${datos.error.message}`);
Respuesta: 201JSON
{
  "id": "we_3a9f0c1d2e4b5a6c7d8e9f01",
  "object": "webhook_endpoint",
  "url": "https://tu-servidor.cl/webhooks/astrobit",
  "events": [
    "certificate.paid",
    "certificate.issued",
    "certificate.expiring"
  ],
  "enabled": true,
  "created_at": "2026-09-27T15:00:00Z",
  "updated_at": "2026-09-27T15:00:00Z",
  "secret": "whsec_Q2hhbmdlTWUtVGhpc0lzT25seUFuRXhhbXBsZTEyMzQ"
}

Errores propios: invalid_url, invalid_events, too_many_webhooks, insufficient_scope.

Ver un webhook

GET/webhooks/{id}alcance: webhooks:manage

Un webhook por su id, sin el secreto.

En la ruta

NombreTipoDescripción
idstringobligatorioEl id del webhook (we_…).
curlbash
curl "https://hankos.cl/api/v1/webhooks/we_3a9f0c1d2e4b5a6c7d8e9f01" \
  -H "Authorization: Bearer $ASTROBIT_API_KEY"
fetchJavaScript
const res = await fetch('https://hankos.cl/api/v1/webhooks/we_3a9f0c1d2e4b5a6c7d8e9f01', {
  headers: {
    Authorization: `Bearer ${process.env.ASTROBIT_API_KEY}`,
  },
});
const datos = await res.json();
if (!res.ok) throw new Error(`${datos.error.code}: ${datos.error.message}`);
Respuesta: 200JSON
{
  "id": "we_3a9f0c1d2e4b5a6c7d8e9f01",
  "object": "webhook_endpoint",
  "url": "https://tu-servidor.cl/webhooks/astrobit",
  "events": [
    "certificate.paid",
    "certificate.issued",
    "certificate.expiring"
  ],
  "enabled": true,
  "created_at": "2026-09-27T15:00:00Z",
  "updated_at": "2026-09-27T15:00:00Z"
}

Errores propios: not_found.

Modificar un webhook

PATCH/webhooks/{id}alcance: webhooks:manage

Cambia la URL, los eventos o si está activo. Manda sólo lo que cambia. Un webhook pausado (enabled: false) no recibe entregas. Cambiar la URL o los eventos pide también certificates:read.

En la ruta

NombreTipoDescripción
idstringobligatorioEl id del webhook (we_…).

En el cuerpo (JSON)

NombreTipoDescripción
urlstringopcionalLa URL nueva, con las mismas reglas que al crear.
eventsstring[]opcionalLa lista nueva de eventos (reemplaza la anterior).
enabledbooleanopcionalfalse para pausarlo, true para reactivarlo.
curlbash
curl -X PATCH "https://hankos.cl/api/v1/webhooks/we_3a9f0c1d2e4b5a6c7d8e9f01" \
  -H "Authorization: Bearer $ASTROBIT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
  "enabled": false
}'
fetchJavaScript
const res = await fetch('https://hankos.cl/api/v1/webhooks/we_3a9f0c1d2e4b5a6c7d8e9f01', {
  method: 'PATCH',
  headers: {
    Authorization: `Bearer ${process.env.ASTROBIT_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    "enabled": false
  }),
});
const datos = await res.json();
if (!res.ok) throw new Error(`${datos.error.code}: ${datos.error.message}`);
Respuesta: 200JSON
{
  "id": "we_3a9f0c1d2e4b5a6c7d8e9f01",
  "object": "webhook_endpoint",
  "url": "https://tu-servidor.cl/webhooks/astrobit",
  "events": [
    "certificate.paid",
    "certificate.issued",
    "certificate.expiring"
  ],
  "enabled": false,
  "created_at": "2026-09-27T15:00:00Z",
  "updated_at": "2026-10-02T09:00:00Z"
}

Errores propios: invalid_url, invalid_events, insufficient_scope, not_found.

Eliminar un webhook

DELETE/webhooks/{id}alcance: webhooks:manage

Deja de enviarle eventos y lo borra.

En la ruta

NombreTipoDescripción
idstringobligatorioEl id del webhook (we_…).
curlbash
curl -X DELETE "https://hankos.cl/api/v1/webhooks/we_3a9f0c1d2e4b5a6c7d8e9f01" \
  -H "Authorization: Bearer $ASTROBIT_API_KEY"
fetchJavaScript
const res = await fetch('https://hankos.cl/api/v1/webhooks/we_3a9f0c1d2e4b5a6c7d8e9f01', {
  method: 'DELETE',
  headers: {
    Authorization: `Bearer ${process.env.ASTROBIT_API_KEY}`,
  },
});
const datos = await res.json();
if (!res.ok) throw new Error(`${datos.error.code}: ${datos.error.message}`);
Respuesta: 200JSON
{
  "id": "we_3a9f0c1d2e4b5a6c7d8e9f01",
  "object": "webhook_endpoint",
  "deleted": true
}

Errores propios: not_found.

Rotar el secreto

POST/webhooks/{id}/rotate-secretalcance: webhooks:manage

Genera un secreto nuevo y lo devuelve una sola vez. El anterior deja de valer en ese momento: actualiza tu servidor enseguida.

En la ruta

NombreTipoDescripción
idstringobligatorioEl id del webhook (we_…).
curlbash
curl -X POST "https://hankos.cl/api/v1/webhooks/we_3a9f0c1d2e4b5a6c7d8e9f01/rotate-secret" \
  -H "Authorization: Bearer $ASTROBIT_API_KEY"
fetchJavaScript
const res = await fetch('https://hankos.cl/api/v1/webhooks/we_3a9f0c1d2e4b5a6c7d8e9f01/rotate-secret', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.ASTROBIT_API_KEY}`,
  },
});
const datos = await res.json();
if (!res.ok) throw new Error(`${datos.error.code}: ${datos.error.message}`);
Respuesta: 200JSON
{
  "id": "we_3a9f0c1d2e4b5a6c7d8e9f01",
  "object": "webhook_endpoint",
  "url": "https://tu-servidor.cl/webhooks/astrobit",
  "events": [
    "certificate.paid",
    "certificate.issued",
    "certificate.expiring"
  ],
  "enabled": true,
  "created_at": "2026-09-27T15:00:00Z",
  "updated_at": "2026-10-02T09:00:00Z",
  "secret": "whsec_TnVldm9TZWNyZXRvRGVFamVtcGxvUGFyYVJvdGFyMDE"
}

Errores propios: not_found.

Enviar una prueba

POST/webhooks/{id}/testalcance: webhooks:manage

Encola un evento ping para ese webhook. Sirve para probar la verificación de la firma. La entrega sale en segundo plano y aparece en tu cuenta. Sólo para un webhook activo.

En la ruta

NombreTipoDescripción
idstringobligatorioEl id del webhook (we_…).
curlbash
curl -X POST "https://hankos.cl/api/v1/webhooks/we_3a9f0c1d2e4b5a6c7d8e9f01/test" \
  -H "Authorization: Bearer $ASTROBIT_API_KEY"
fetchJavaScript
const res = await fetch('https://hankos.cl/api/v1/webhooks/we_3a9f0c1d2e4b5a6c7d8e9f01/test', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.ASTROBIT_API_KEY}`,
  },
});
const datos = await res.json();
if (!res.ok) throw new Error(`${datos.error.code}: ${datos.error.message}`);
Respuesta: 202JSON
{
  "event_id": "evt_9e4a2c7b1d0f3e5a8c6b2d41"
}

Errores propios: webhook_disabled, not_found.

Webhooks

Un webhook es una URL tuya a la que te avisamos cada vez que un certificado cambia de estado. Así no tienes que consultar cada tanto. Puedes registrar hasta 5 por cuenta, en tu cuenta o con POST /webhooks.

  • La URL tiene que ser https, en un host público y en el puerto 443. No se aceptan IP privadas ni localhost.
  • Al crearlo te damos el secreto de firma (whsec_ y 43 caracteres), una sola vez. Si lo pierdes, rótalo.
  • Los eventos se generan para las cuentas que existen: un correo que ya entró a su cuenta o usó la API.

Eventos

typeCuándoAdemás en data
certificate.paidSe confirmó el pago. El titular tiene que verificar su identidad en identity_url.—
certificate.issuedSe emitió el certificado de una compra nueva.—
certificate.renewedSe emitió el certificado de una renovación.previous_certificate_id: el certificado renovado.
certificate.expiringFaltan 30 días para el vencimiento, y de nuevo cuando faltan 7.days_left: los días que faltan.
certificate.expiredEl certificado venció.—
certificate.revokedEl certificado fue revocado.reason: el motivo, como en POST /revoke.
certificate.canceledLa compra se anuló: no se pagó a tiempo, se devolvió el pago o no se pudo verificar la identidad.reason: payment_expired, refunded o identity_rejected.
subscription.renewal_upcomingSuscripción anual: faltan 30 días para el cobro de la renovación (unos 51 días antes del vencimiento). Una vez por año.charge_at, amount (CLP) y card_on_file.
subscription.canceledSe canceló una suscripción anual (desde la cuenta o la API).subscription_id.
pingLa prueba de POST /webhooks/{id}/test.webhook_id. No trae certificado.

certificate.expiring se envía una vez a los 30 días y otra a los 7; si el certificado aparece con menos de 7 días por delante, sólo la de 7. certificate.expired se envía una vez, dentro de los 30 días siguientes al vencimiento.

El objeto event

El cuerpo de cada entrega es un evento. data.certificate es el certificado tal como quedó, en todos los eventos certificate.*.

certificate.paid
Ejemplo de certificate.paidJSON
{
  "id": "evt_5c2e8a1f0b9d7c3e6a4f2b18",
  "object": "event",
  "type": "certificate.paid",
  "created_at": "2026-09-27T14:05:41Z",
  "data": {
    "certificate": {
      "id": "cert-8f2c1a9b0d3e4f5a6b7c8d9e",
      "object": "certificate",
      "status": "awaiting_identity",
      "months": 12,
      "price": {
        "amount": 8990,
        "currency": "CLP",
        "tax_included": true
      },
      "document": "boleta",
      "holder": {
        "rut": "12345678-5",
        "name": null,
        "email": "compras@empresa.cl",
        "phone": "+56912345678"
      },
      "buyer": {
        "email": "compras@empresa.cl",
        "rut": null,
        "name": null
      },
      "renews": null,
      "renewed_by": null,
      "created_at": "2026-09-27T14:03:11Z",
      "paid_at": "2026-09-27T14:05:40Z",
      "issued_at": null,
      "expires_at": null,
      "revoked_at": null,
      "renewal": null,
      "identity_url": "https://hankos.cl/pago/ok?ref=cert-8f2c1a9b0d3e4f5a6b7c8d9e",
      "payment_url": null,
      "cancel_reason": null,
      "subscription": null
    }
  }
}
certificate.issued
Ejemplo de certificate.issuedJSON
{
  "id": "evt_5c2e8a1f0b9d7c3e6a4f2b18",
  "object": "event",
  "type": "certificate.issued",
  "created_at": "2026-09-27T16:20:03Z",
  "data": {
    "certificate": {
      "id": "cert-8f2c1a9b0d3e4f5a6b7c8d9e",
      "object": "certificate",
      "status": "active",
      "months": 12,
      "price": {
        "amount": 8990,
        "currency": "CLP",
        "tax_included": true
      },
      "document": "boleta",
      "holder": {
        "rut": "12345678-5",
        "name": "Juana Pérez",
        "email": "compras@empresa.cl",
        "phone": "+56912345678"
      },
      "buyer": {
        "email": "compras@empresa.cl",
        "rut": null,
        "name": null
      },
      "renews": null,
      "renewed_by": null,
      "created_at": "2026-09-27T14:03:11Z",
      "paid_at": "2026-09-27T14:05:40Z",
      "issued_at": "2026-09-27T16:20:02Z",
      "expires_at": "2027-09-27T16:20:02Z",
      "revoked_at": null,
      "renewal": {
        "opens_at": "2027-08-28T16:20:02Z",
        "open": false
      },
      "identity_url": null,
      "payment_url": null,
      "cancel_reason": null,
      "subscription": null
    }
  }
}
certificate.renewed
Ejemplo de certificate.renewedJSON
{
  "id": "evt_5c2e8a1f0b9d7c3e6a4f2b18",
  "object": "event",
  "type": "certificate.renewed",
  "created_at": "2027-09-02T11:30:41Z",
  "data": {
    "certificate": {
      "id": "cert-1b7e0c2d4a6f8e9c3b5d7a10",
      "object": "certificate",
      "status": "active",
      "months": 12,
      "price": {
        "amount": 8990,
        "currency": "CLP",
        "tax_included": true
      },
      "document": "boleta",
      "holder": {
        "rut": "12345678-5",
        "name": "Juana Pérez",
        "email": "compras@empresa.cl",
        "phone": "+56912345678"
      },
      "buyer": {
        "email": "compras@empresa.cl",
        "rut": null,
        "name": null
      },
      "renews": "cert-8f2c1a9b0d3e4f5a6b7c8d9e",
      "renewed_by": null,
      "created_at": "2027-09-01T10:12:45Z",
      "paid_at": "2027-09-01T10:15:02Z",
      "issued_at": "2027-09-02T11:30:40Z",
      "expires_at": "2028-09-02T11:30:40Z",
      "revoked_at": null,
      "renewal": {
        "opens_at": "2028-08-03T11:30:40Z",
        "open": false
      },
      "identity_url": null,
      "payment_url": null,
      "cancel_reason": null,
      "subscription": null
    },
    "previous_certificate_id": "cert-8f2c1a9b0d3e4f5a6b7c8d9e"
  }
}
certificate.expiring
Ejemplo de certificate.expiringJSON
{
  "id": "evt_5c2e8a1f0b9d7c3e6a4f2b18",
  "object": "event",
  "type": "certificate.expiring",
  "created_at": "2027-08-28T16:20:05Z",
  "data": {
    "certificate": {
      "id": "cert-8f2c1a9b0d3e4f5a6b7c8d9e",
      "object": "certificate",
      "status": "active",
      "months": 12,
      "price": {
        "amount": 8990,
        "currency": "CLP",
        "tax_included": true
      },
      "document": "boleta",
      "holder": {
        "rut": "12345678-5",
        "name": "Juana Pérez",
        "email": "compras@empresa.cl",
        "phone": "+56912345678"
      },
      "buyer": {
        "email": "compras@empresa.cl",
        "rut": null,
        "name": null
      },
      "renews": null,
      "renewed_by": null,
      "created_at": "2026-09-27T14:03:11Z",
      "paid_at": "2026-09-27T14:05:40Z",
      "issued_at": "2026-09-27T16:20:02Z",
      "expires_at": "2027-09-27T16:20:02Z",
      "revoked_at": null,
      "renewal": {
        "opens_at": "2027-08-28T16:20:02Z",
        "open": true
      },
      "identity_url": null,
      "payment_url": null,
      "cancel_reason": null,
      "subscription": null
    },
    "days_left": 30
  }
}
certificate.expired
Ejemplo de certificate.expiredJSON
{
  "id": "evt_5c2e8a1f0b9d7c3e6a4f2b18",
  "object": "event",
  "type": "certificate.expired",
  "created_at": "2027-09-27T16:21:00Z",
  "data": {
    "certificate": {
      "id": "cert-8f2c1a9b0d3e4f5a6b7c8d9e",
      "object": "certificate",
      "status": "expired",
      "months": 12,
      "price": {
        "amount": 8990,
        "currency": "CLP",
        "tax_included": true
      },
      "document": "boleta",
      "holder": {
        "rut": "12345678-5",
        "name": "Juana Pérez",
        "email": "compras@empresa.cl",
        "phone": "+56912345678"
      },
      "buyer": {
        "email": "compras@empresa.cl",
        "rut": null,
        "name": null
      },
      "renews": null,
      "renewed_by": null,
      "created_at": "2026-09-27T14:03:11Z",
      "paid_at": "2026-09-27T14:05:40Z",
      "issued_at": "2026-09-27T16:20:02Z",
      "expires_at": "2027-09-27T16:20:02Z",
      "revoked_at": null,
      "renewal": {
        "opens_at": "2027-08-28T16:20:02Z",
        "open": true
      },
      "identity_url": null,
      "payment_url": null,
      "cancel_reason": null,
      "subscription": null
    }
  }
}
certificate.revoked
Ejemplo de certificate.revokedJSON
{
  "id": "evt_5c2e8a1f0b9d7c3e6a4f2b18",
  "object": "event",
  "type": "certificate.revoked",
  "created_at": "2027-03-02T12:44:11Z",
  "data": {
    "certificate": {
      "id": "cert-8f2c1a9b0d3e4f5a6b7c8d9e",
      "object": "certificate",
      "status": "revoked",
      "months": 12,
      "price": {
        "amount": 8990,
        "currency": "CLP",
        "tax_included": true
      },
      "document": "boleta",
      "holder": {
        "rut": "12345678-5",
        "name": "Juana Pérez",
        "email": "compras@empresa.cl",
        "phone": "+56912345678"
      },
      "buyer": {
        "email": "compras@empresa.cl",
        "rut": null,
        "name": null
      },
      "renews": null,
      "renewed_by": null,
      "created_at": "2026-09-27T14:03:11Z",
      "paid_at": "2026-09-27T14:05:40Z",
      "issued_at": "2026-09-27T16:20:02Z",
      "expires_at": "2027-09-27T16:20:02Z",
      "revoked_at": "2027-03-02T12:44:10Z",
      "renewal": {
        "opens_at": "2027-08-28T16:20:02Z",
        "open": false
      },
      "identity_url": null,
      "payment_url": null,
      "cancel_reason": null,
      "subscription": null
    },
    "reason": "key_compromise"
  }
}
certificate.canceled
Ejemplo de certificate.canceledJSON
{
  "id": "evt_5c2e8a1f0b9d7c3e6a4f2b18",
  "object": "event",
  "type": "certificate.canceled",
  "created_at": "2026-09-28T14:03:30Z",
  "data": {
    "certificate": {
      "id": "cert-8f2c1a9b0d3e4f5a6b7c8d9e",
      "object": "certificate",
      "status": "canceled",
      "months": 12,
      "price": {
        "amount": 8990,
        "currency": "CLP",
        "tax_included": true
      },
      "document": "boleta",
      "holder": {
        "rut": "12345678-5",
        "name": null,
        "email": "compras@empresa.cl",
        "phone": "+56912345678"
      },
      "buyer": {
        "email": "compras@empresa.cl",
        "rut": null,
        "name": null
      },
      "renews": null,
      "renewed_by": null,
      "created_at": "2026-09-27T14:03:11Z",
      "paid_at": null,
      "issued_at": null,
      "expires_at": null,
      "revoked_at": null,
      "renewal": null,
      "identity_url": null,
      "payment_url": null,
      "cancel_reason": "payment_expired",
      "subscription": null
    },
    "reason": "payment_expired"
  }
}
subscription.renewal_upcoming
Ejemplo de subscription.renewal_upcomingJSON
{
  "id": "evt_5c2e8a1f0b9d7c3e6a4f2b18",
  "object": "event",
  "type": "subscription.renewal_upcoming",
  "created_at": "2027-08-07T16:20:05Z",
  "data": {
    "certificate": {
      "id": "cert-8f2c1a9b0d3e4f5a6b7c8d9e",
      "object": "certificate",
      "status": "active",
      "months": 12,
      "price": {
        "amount": 8990,
        "currency": "CLP",
        "tax_included": true
      },
      "document": "boleta",
      "holder": {
        "rut": "12345678-5",
        "name": "Juana Pérez",
        "email": "compras@empresa.cl",
        "phone": "+56912345678"
      },
      "buyer": {
        "email": "compras@empresa.cl",
        "rut": null,
        "name": null
      },
      "renews": null,
      "renewed_by": null,
      "created_at": "2026-09-27T14:03:11Z",
      "paid_at": "2026-09-27T14:05:40Z",
      "issued_at": "2026-09-27T16:20:02Z",
      "expires_at": "2027-09-27T16:20:02Z",
      "revoked_at": null,
      "renewal": {
        "opens_at": "2027-08-28T16:20:02Z",
        "open": false
      },
      "identity_url": null,
      "payment_url": null,
      "cancel_reason": null,
      "subscription": {
        "id": "sub_7d1e9a4c2b6f8e0a3c5d7b92",
        "status": "active",
        "card_on_file": true,
        "next_charge_at": "2027-09-06T16:20:02Z",
        "next_amount": 7590,
        "enroll_url": null
      }
    },
    "charge_at": "2027-09-06T16:20:02Z",
    "amount": 7590,
    "card_on_file": true
  }
}
subscription.canceled
Ejemplo de subscription.canceledJSON
{
  "id": "evt_5c2e8a1f0b9d7c3e6a4f2b18",
  "object": "event",
  "type": "subscription.canceled",
  "created_at": "2027-02-11T09:14:30Z",
  "data": {
    "certificate": {
      "id": "cert-8f2c1a9b0d3e4f5a6b7c8d9e",
      "object": "certificate",
      "status": "active",
      "months": 12,
      "price": {
        "amount": 8990,
        "currency": "CLP",
        "tax_included": true
      },
      "document": "boleta",
      "holder": {
        "rut": "12345678-5",
        "name": "Juana Pérez",
        "email": "compras@empresa.cl",
        "phone": "+56912345678"
      },
      "buyer": {
        "email": "compras@empresa.cl",
        "rut": null,
        "name": null
      },
      "renews": null,
      "renewed_by": null,
      "created_at": "2026-09-27T14:03:11Z",
      "paid_at": "2026-09-27T14:05:40Z",
      "issued_at": "2026-09-27T16:20:02Z",
      "expires_at": "2027-09-27T16:20:02Z",
      "revoked_at": null,
      "renewal": {
        "opens_at": "2027-08-28T16:20:02Z",
        "open": false
      },
      "identity_url": null,
      "payment_url": null,
      "cancel_reason": null,
      "subscription": {
        "id": "sub_7d1e9a4c2b6f8e0a3c5d7b92",
        "status": "canceled",
        "card_on_file": true,
        "next_charge_at": null,
        "next_amount": null,
        "enroll_url": null
      }
    },
    "subscription_id": "sub_7d1e9a4c2b6f8e0a3c5d7b92"
  }
}
ping
Ejemplo de pingJSON
{
  "id": "evt_5c2e8a1f0b9d7c3e6a4f2b18",
  "object": "event",
  "type": "ping",
  "created_at": "2026-09-27T15:01:12Z",
  "data": {
    "webhook_id": "we_3a9f0c1d2e4b5a6c7d8e9f01"
  }
}

La entrega

Cada evento llega como un POST a tu URL, con el evento en el cuerpo y estas cabeceras:

CabeceraValor
Content-Typeapplication/json
User-AgentAstrobit-Webhooks/1.0
Astrobit-Event-IdEl id del evento (evt_…). Úsalo para deduplicar.
Astrobit-Event-TypeEl tipo (certificate.issued, ping…).
Astrobit-Signaturet=<epoch en segundos>,v1=<firma hex>

Una entrega es exitosa si tu servidor responde un código 2xx en 10 segundos o menos. No seguimos redirecciones: un 301 o 302 cuenta como falla.

Reintentos

Si falla, reintentamos hasta completar 8 intentos. Cada espera se cuenta desde el intento anterior:

IntentoEspera
1Ninguna: sale apenas ocurre el evento
21 minuto
35 minutos
430 minutos
52 horas
66 horas
712 horas
824 horas; es el último

Las entregas y sus intentos aparecen en tu cuenta. Si se agotan, los eventos siguen disponibles 30 días en GET /events.

Buenas prácticas

  • Verifica la firma antes de hacer nada con el evento. Ver la sección siguiente.
  • Responde 2xx rápido y procesa después (una cola, un trabajo en segundo plano). Si tardas más de 10 segundos, la entrega cuenta como fallida y se reintenta.
  • Deduplica por Astrobit-Event-Id (es el id del evento): un mismo evento puede llegar dos veces.
  • No asumas orden. Los eventos pueden llegar desordenados; si necesitas el estado actual, consulta GET /certificates/{id}.
  • Prueba con POST /webhooks/{id}/test, que te manda un ping.

Verificar la firma

Cada entrega trae la cabecera Astrobit-Signature:

HTTP
Astrobit-Signature: t=1790517941,v1=5f2b0c…e81a
  1. Separa t (el momento de la firma, en segundos epoch) y v1 (la firma, en hexadecimal).
  2. Arma el texto <t>.<cuerpo crudo>: el valor de t, un punto y el cuerpo exactamente como llegó, byte por byte, antes de parsearlo.
  3. Calcula HMAC-SHA256 de ese texto con tu secreto (el whsec_… completo, tal cual) y pásalo a hexadecimal.
  4. Compáralo con v1 en tiempo constante. Una comparación común filtra, por lo que tarda, cuánto acertó quien prueba firmas.
  5. Rechaza si t tiene más de 5 minutos de diferencia con tu reloj: así nadie te reenvía un evento viejo.

Lo más común que rompe la firma: parsear el JSON y volver a serializarlo antes de verificar. Los bytes cambian y la firma ya no calza. Verifica sobre el cuerpo crudo.

Node.js

ExpressJavaScript
// Node.js 18+ con Express. npm install express
import crypto from 'node:crypto';
import express from 'express';

const SECRETO = process.env.ASTROBIT_WEBHOOK_SECRET; // whsec_…
const TOLERANCIA_SEGUNDOS = 5 * 60;

function verificarFirma(cuerpoCrudo, cabecera, secreto) {
  if (typeof cabecera !== 'string') return false;
  const partes = {};
  for (const par of cabecera.split(',')) {
    const i = par.indexOf('=');
    if (i > 0) partes[par.slice(0, i).trim()] = par.slice(i + 1).trim();
  }
  const { t, v1 } = partes;
  if (!/^\d+$/.test(t ?? '') || !/^[0-9a-f]{64}$/i.test(v1 ?? '')) return false;

  // Más de 5 minutos de diferencia: se rechaza (evita que se reenvíe uno viejo).
  const ahora = Math.floor(Date.now() / 1000);
  if (Math.abs(ahora - Number(t)) > TOLERANCIA_SEGUNDOS) return false;

  const esperada = crypto
    .createHmac('sha256', secreto)
    .update(`${t}.`)
    .update(cuerpoCrudo) // los bytes tal como llegaron, sin parsear
    .digest();
  const recibida = Buffer.from(v1, 'hex');
  // Comparación en tiempo constante.
  return recibida.length === esperada.length && crypto.timingSafeEqual(recibida, esperada);
}

const app = express();
const procesados = new Set(); // en producción: tu base de datos

// express.raw: el cuerpo llega como Buffer, sin tocar. Con express.json()
// se pierde el cuerpo crudo y la firma no calza.
app.post('/webhooks/astrobit', express.raw({ type: 'application/json' }), (req, res) => {
  if (!verificarFirma(req.body, req.get('Astrobit-Signature'), SECRETO)) {
    return res.sendStatus(400);
  }

  const evento = JSON.parse(req.body.toString('utf8'));
  // Responde 2xx rápido; el trabajo pesado, después.
  res.sendStatus(200);

  if (procesados.has(evento.id)) return; // puede llegar dos veces
  procesados.add(evento.id);
  encolar(evento);
});

function encolar(evento) {
  // p. ej. si evento.type === 'certificate.expiring', avisar al titular o renovar.
  console.log('evento', evento.type, evento.data.certificate?.id);
}

app.listen(3000);
Next.js (App Router)TypeScript
// Next.js (App Router): app/api/webhooks/astrobit/route.ts
import crypto from 'node:crypto';

export async function POST(req: Request) {
  const cuerpo = Buffer.from(await req.arrayBuffer()); // crudo, antes de parsear
  const cabecera = req.headers.get('astrobit-signature') ?? '';
  const partes = Object.fromEntries(
    cabecera.split(',').map((p) => [p.slice(0, p.indexOf('=')).trim(), p.slice(p.indexOf('=') + 1).trim()]),
  );
  const t: string = partes.t ?? '';
  const v1: string = partes.v1 ?? '';
  const vigente = /^\d+$/.test(t) && Math.abs(Date.now() / 1000 - Number(t)) <= 300;
  const esperada = crypto.createHmac('sha256', process.env.ASTROBIT_WEBHOOK_SECRET!).update(`${t}.`).update(cuerpo).digest();
  const recibida = Buffer.from(/^[0-9a-f]{64}$/i.test(v1) ? v1 : '', 'hex');
  if (!vigente || recibida.length !== esperada.length || !crypto.timingSafeEqual(recibida, esperada)) {
    return new Response('firma inválida', { status: 400 });
  }
  const evento = JSON.parse(cuerpo.toString('utf8'));
  // …deduplicar por evento.id y encolar…
  return new Response(null, { status: 200 });
}

Python

FlaskPython
# Python 3.8+ con Flask. pip install flask
import hashlib
import hmac
import json
import os
import time

from flask import Flask, abort, request

SECRETO = os.environ["ASTROBIT_WEBHOOK_SECRET"]  # whsec_…
TOLERANCIA_SEGUNDOS = 5 * 60


def verificar_firma(cuerpo_crudo: bytes, cabecera: str, secreto: str) -> bool:
    partes = {}
    for par in (cabecera or "").split(","):
        clave, _, valor = par.partition("=")
        if clave and valor:
            partes[clave.strip()] = valor.strip()
    t, v1 = partes.get("t", ""), partes.get("v1", "")
    if not t.isdigit() or len(v1) != 64:
        return False

    # Más de 5 minutos de diferencia: se rechaza.
    if abs(time.time() - int(t)) > TOLERANCIA_SEGUNDOS:
        return False

    esperada = hmac.new(
        secreto.encode("utf-8"),
        t.encode("ascii") + b"." + cuerpo_crudo,  # los bytes tal como llegaron
        hashlib.sha256,
    ).hexdigest()
    # Comparación en tiempo constante.
    return hmac.compare_digest(esperada, v1.lower())


app = Flask(__name__)
procesados = set()  # en producción: tu base de datos


@app.post("/webhooks/astrobit")
def recibir():
    cuerpo = request.get_data()  # crudo, antes de parsear
    if not verificar_firma(cuerpo, request.headers.get("Astrobit-Signature", ""), SECRETO):
        abort(400)

    evento = json.loads(cuerpo)
    if evento["id"] not in procesados:  # puede llegar dos veces
        procesados.add(evento["id"])
        # encola el trabajo; responde rápido
    return "", 200

PHP

Sin dependenciasPHP
<?php
// PHP 7.4+, sin dependencias.

const TOLERANCIA_SEGUNDOS = 300;

function verificarFirma(string $cuerpoCrudo, string $cabecera, string $secreto): bool
{
    $partes = [];
    foreach (explode(',', $cabecera) as $par) {
        $kv = explode('=', trim($par), 2);
        if (count($kv) === 2) {
            $partes[trim($kv[0])] = trim($kv[1]);
        }
    }
    $t = $partes['t'] ?? '';
    $v1 = strtolower($partes['v1'] ?? '');
    if (!ctype_digit($t) || strlen($v1) !== 64) {
        return false;
    }

    // Más de 5 minutos de diferencia: se rechaza.
    if (abs(time() - (int) $t) > TOLERANCIA_SEGUNDOS) {
        return false;
    }

    $esperada = hash_hmac('sha256', $t . '.' . $cuerpoCrudo, $secreto);
    // Comparación en tiempo constante.
    return hash_equals($esperada, $v1);
}

$cuerpo = file_get_contents('php://input'); // crudo, antes de parsear
$firma = $_SERVER['HTTP_ASTROBIT_SIGNATURE'] ?? '';

if (!verificarFirma($cuerpo, $firma, getenv('ASTROBIT_WEBHOOK_SECRET'))) {
    http_response_code(400);
    exit;
}

$evento = json_decode($cuerpo, true);
// Deduplica por $evento['id'] (o $_SERVER['HTTP_ASTROBIT_EVENT_ID']),
// encola el trabajo y responde rápido.
http_response_code(200);

Renovación

La renovación se abre 30 días antes del vencimiento (renewal.opens_at) y se hace con POST /certificates/{id}/renew. Por omisión usa la vigencia, el documento y la empresa del certificado original; puedes cambiarlos en el cuerpo. El comprador es tu cuenta.

El titular tiene que verificar su identidad otra vez. Astrobit sólo emite con una verificación de identidad de menos de 30 días, así que toda emisión, nueva o renovación, la pide después del pago. Cuando la renovación se paga, llega certificate.paid con el identity_url para el titular.

  • Si la ventana todavía no abre, 409 not_renewable con error.renewal.opens_at.
  • Si ya hay una renovación en curso, 409 not_renewable con error.pending_renewal: paga esa en vez de crear otra.
  • Si el certificado ya venció, puedes renovarlo mientras renewal.open sea true. Si no, compra uno nuevo con POST /certificates.
  • El certificado que se renueva sigue vigente hasta su fecha. El nuevo llega con certificate.renewed y data.previous_certificate_id; el anterior queda con renewed_by, el id de la renovación más nueva en cualquier estado. Mira esa compra con GET /certificates/{id}: si está en curso o ya se emitió, no crees otra; si quedó canceled (venció sin pagarse, por ejemplo), puedes renovar de nuevo.
  • Un certificado de una suscripción activa (subscription.status = active) se renueva con la suscripción, que lo cobra sola. No lo renueves a mano: se cobraría el mismo año dos veces. Para renovarlo a mano, primero cancela la suscripción.
  • Los días que quedan no se pierden. Toda renovación (por la API, desde la cuenta o de una suscripción) de un certificado todavía vigente suma al nuevo los días que le quedaban al anterior, hasta 45: el nuevo vence un período completo después del vencimiento del anterior, no después de emitirse. Renovar temprano no cuesta días.

Un buen flujo: al recibir certificate.expiring de un certificado sin una suscripción activa, renueva si no tiene renewed_by o si esa renovación quedó canceled, con una Idempotency-Key derivada del evento (renovar-evt_5c2e8a1f0b9d7c3e6a4f2b18), y manda el payment_url a quien paga. Con el id del evento, el reintento de una misma entrega cae en la misma compra, y el aviso siguiente (el de los 7 días) crea otra si la primera venció sin pagarse. Si entre medio se abrió otra renovación, la respuesta es 409 not_renewable con error.pending_renewal: pide su enlace con POST /certificates/{id}/payment-link en vez de crear otra.

Suscripciones

La suscripción anual es un certificado de 12 meses que se renueva solo cada año, cobrado a una tarjeta inscrita en Webpay Oneclick. Desde el segundo año cuesta menos que el primero; los precios de cada año están en la página de compra. Todos los certificados que emite comparten el mismo subscription.id.

Se compra como cualquier certificado, con subscription: true:

Comprar una suscripción anualbash
curl -X POST "https://hankos.cl/api/v1/certificates"   -H "Authorization: Bearer $ASTROBIT_API_KEY"   -H "Idempotency-Key: pedido-8841-certificado"   -H "Content-Type: application/json"   -d '{
  "months": 12,
  "subscription": true,
  "holder": {
    "rut": "12.345.678-5",
    "phone": "9 1234 5678"
  }
}'

Al mandar subscription: true declaras que quien compra aceptó que el certificado se renueve y se cobre cada año a su tarjeta hasta que cancele. La vigencia es siempre de 12 meses.

Ciclo de una suscripción

  1. Primer año. La compra nace en pending_payment y se paga en payment_url, con Webpay, como cualquier otra.
  2. Pagado. Llega certificate.paid. El titular verifica su identidad en identity_url, y quien compró inscribe su tarjeta en subscription.enroll_url. Son independientes: el certificado se emite aunque la tarjeta no esté inscrita.
  3. Emitido. Llega certificate.issued, y subscription.next_charge_at queda 21 días antes del vencimiento, con el monto del año siguiente en subscription.next_amount.
  4. Aviso. 30 días antes del cobro llega subscription.renewal_upcoming, con charge_at, amount y card_on_file. Si no hay tarjeta inscrita, es el momento de inscribirla en subscription.enroll_url: sin tarjeta, la renovación no se cobra ni se emite, y el certificado vence en su fecha.
  5. Cobro. Cuando el cobro queda pagado, se crea la compra de la renovación, ya pagada (renews = el certificado anterior), y llega certificate.paid con su identity_url: el titular verifica su identidad otra vez, porque Astrobit sólo emite con una verificación de menos de 30 días.
  6. Renovado. Llega certificate.renewed. El nuevo certificado vence exactamente un año después del anterior, porque los días que le quedaban se suman. Y el ciclo sigue en el paso 3.

Cancelar

En cualquier momento, desde la cuenta o con POST /certificates/{id}/subscription/cancel. No hay más cobros; el certificado vigente sigue funcionando hasta su vencimiento, y el año en curso no se reembolsa. Llega subscription.canceled. Después, el certificado se renueva a mano como cualquier otro.

Enlaces de pago

Comprar, renovar y pedir un enlace devuelven un payment_url de la forma:

URL
https://hankos.cl/pagar/cert-…?e=<vencimiento>&s=<firma>
  • Vale 24 horas, lo mismo que dura una compra sin pagar. Lo puede abrir cualquiera que lo tenga: mándalo sólo a quien paga, y úsalo tal como viene (está firmado; si se modifica, deja de servir).
  • Al abrirlo, la persona ve la compra (vigencia, titular, documento y total) y un botón para pagar en Webpay. Si la compra ya está pagada, llega a la página de su estado.
  • Una compra tiene un solo cobro de Webpay, que se crea cuando la persona aprieta Pagar y dura 2 horas. Abrir el enlace no crea nada, así que un antivirus o un filtro de correo que lo revise antes no lo gasta. Si esas 2 horas pasan sin pago, esa compra ya no se puede pagar: crea otra.
  • Si perdiste el enlace, pide otro con POST /certificates/{id}/payment-link. Si la compra ya no espera pago, la respuesta es 409 not_payable.

Precios

Los precios vigentes están en GET /prices y en la página de compra: son los mismos, en pesos chilenos, con IVA incluido, y son los que se cobran. Consúltalos en vez de escribirlos en tu código.

Cuando hay una oferta por tiempo limitado, GET /prices la trae en promo, con su fecha de término real en ends_at, y cada vigencia con descuento trae su precio normal en regular_amount. El precio de la oferta vale para las compras creadas antes de ends_at; después, POST /certificates cobra el precio normal.

La API no tiene costo aparte: no se cobra por llave, por llamada ni por volumen. Pagas sólo los certificados.

Versionado y cambios

Esta es la versión 1, y va en la ruta (/api/v1). Dentro de v1 sólo hacemos cambios que no rompen:

  • agregar endpoints, campos opcionales en el cuerpo o campos nuevos en las respuestas;
  • agregar tipos de evento (un webhook con * los recibe) y códigos de error;
  • cambiar el texto de message.

Por eso tu integración tiene que ignorar los campos que no conoce, y decidir por code y no por message. Un cambio que rompa (quitar o renombrar un campo, cambiar un tipo) irá en una versión nueva, con aviso por correo a las cuentas con llaves activas y con v1 funcionando en paralelo.

Soporte

Escríbenos por WhatsApp al +56 9 2825 4794 o a contacto@astrobit.cl. Para que lo revisemos rápido, incluye el endpoint, la hora (con zona horaria), el code del error y, si corresponde, el id del certificado (cert-…) o del evento (evt_…). Nunca nos mandes tu llave de API ni el secreto de un webhook.