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:
https://hankos.cl/api/v1- Todo es JSON en UTF-8. Manda
Content-Type: application/jsoncuando 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
- Entra a tu cuenta con tu correo y crea una llave de API.
- Prueba la llave con GET /prices.
- Registra un webhook y compra con POST /certificates.
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:
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.
| Alcance | Permite |
|---|---|
certificates:read | Listar y ver certificados, precios y eventos. |
certificates:write | Comprar, renovar, revocar y pedir enlaces de pago. |
certificates:download | Descargar el .pfx y su contraseña. |
webhooks:manage | Administrar 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:downloadentrega 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.
{
"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
| type | HTTP | Significa |
|---|---|---|
invalid_request | 400, 413, 422 | La solicitud está mal formada o un dato no es válido. param dice cuál. |
authentication | 401 | Falta la llave, no existe o está revocada. |
permission | 403 | La llave existe, pero no tiene el alcance que pide el endpoint. |
not_found | 404 | El recurso no existe o no es de tu cuenta. |
conflict | 409 | La operación no se puede hacer en el estado actual del recurso. |
rate_limit | 429 | Pasaste el límite de solicitudes. Espera lo que diga Retry-After. |
api_error | 500, 502, 503 | Un problema nuestro. Reintenta con espera creciente. |
Códigos
| code | type | Significa |
|---|---|---|
missing_api_key | authentication | No mandaste la cabecera Authorization. |
invalid_api_key | authentication | La llave no existe o está mal copiada. |
revoked_api_key | authentication | La llave fue revocada. Crea otra en tu cuenta. |
insufficient_scope | permission | A la llave le falta un alcance; error.scope dice cuál. |
not_found | not_found | No existe, o no es de tu cuenta. |
invalid_json | invalid_request | El cuerpo no es JSON válido (o no es UTF-8). |
months_out_of_range | invalid_request | months está fuera del rango que se vende (ver /prices). |
invalid_rut | invalid_request | Un RUT no es válido (revisa el dígito verificador). param dice cuál. |
holder_not_person | invalid_request | El 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_email | invalid_request | El correo del titular no es válido. |
invalid_name | invalid_request | Mandaste holder.name con más de 64 caracteres. El campo se ignora: no lo mandes. |
invalid_document | invalid_request | document no es boleta ni factura. |
company_rut_required | invalid_request | Pediste factura sin company_rut. |
company_not_found | invalid_request | El SII no tiene una empresa con ese RUT a la cual facturar. |
company_without_activity | invalid_request | El 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_certificate | conflict | El titular ya tiene un certificado vigente. Renuévalo cuando se abra la ventana, 30 días antes del vencimiento. |
subscription_active | conflict | El 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_invalid | invalid_request | Idempotency-Key no tiene entre 16 y 128 caracteres de [A-Za-z0-9_-]. |
idempotency_key_reused | conflict | Usaste la misma Idempotency-Key con otro cuerpo. |
not_renewable | conflict | No 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_active | conflict | La operación exige un certificado active (revocar, descargar). |
already_revoked | conflict | El certificado ya estaba revocado. |
invalid_reason | invalid_request | reason no es uno de los motivos de revocación aceptados. |
not_payable | conflict | La 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_url | invalid_request | La URL del webhook no es https, no es pública o no usa el puerto 443. |
invalid_events | invalid_request | events está vacío o trae un evento que no existe. |
too_many_webhooks | conflict | Ya tienes 5 webhooks, el máximo por cuenta. |
webhook_disabled | conflict | El webhook está pausado: no recibe entregas, tampoco la prueba. Actívalo con PATCH /webhooks/{id}. |
no_subscription | conflict | El certificado no es de una suscripción anual. |
invalid_phone | invalid_request | holder.phone no es un celular chileno. |
not_awaiting_identity | conflict | El certificado no está pagado y esperando la verificación de identidad. |
identity_unavailable | conflict | La verificación de identidad no está disponible para este certificado; lo revisará una persona. |
identity_attempts_exhausted | conflict | Se usaron los 3 intentos de verificación. Revisaremos los datos y escribiremos al titular. |
already_canceled | conflict | La suscripción ya estaba cancelada. |
payload_too_large | invalid_request | El cuerpo pasa de 64 KB (HTTP 413). |
invalid_query | invalid_request | La query es demasiado larga o trae caracteres que no se pueden reenviar tal cual. |
rate_limited | rate_limit | Pasaste 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_unavailable | api_error | El servicio no respondió (HTTP 502). Reintenta en unos segundos. |
internal_error | api_error | Un 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:
| Cabecera | Qué dice |
|---|---|
X-RateLimit-Limit | Solicitudes por ventana (60). |
X-RateLimit-Remaining | Cuántas te quedan en esta ventana. |
X-RateLimit-Reset | Cuándo empieza la ventana siguiente, en segundos epoch. |
Retry-After | Só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ámetro | Qué hace |
|---|---|
limit | Cuántos elementos traer, de 1 a 100. Por omisión, 20. |
starting_after | El id del último elemento que ya tienes: trae los que siguen. |
{
"object": "list",
"data": [
"…"
],
"has_more": true
}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:
Idempotency-Key: pedido-8841-certificado- Entre 16 y 128 caracteres de
A-Z a-z 0-9 _ -. Si no, 400idempotency_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.
{
"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
| Campo | Tipo | Descripción |
|---|---|---|
id | string | La referencia de la compra: cert- y 24 caracteres hexadecimales. Es el mismo id desde la compra hasta el vencimiento. |
object | string | Siempre certificate. |
status | string | El estado. Ver la tabla de estados. |
months | integer | La vigencia comprada, en meses. |
price | object | amount (CLP, entero), currency (CLP) y tax_included (true: IVA incluido). |
document | string | boleta (boleta electrónica, tipo 39) o factura (factura electrónica, tipo 33). |
holder | object | El 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…). |
buyer | object | Quien compró: email (el de la cuenta), y rut y name de la empresa si se pidió factura; si no, null. |
renews | string | null | Si esta compra es una renovación, el id del certificado que renueva. |
renewed_by | string | null | El 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_at | string | Cuándo se creó la compra. |
paid_at | string | null | Cuándo se confirmó el pago. |
issued_at | string | null | Cuándo se emitió el certificado. |
expires_at | string | null | Cuándo vence. null hasta que se emite. |
revoked_at | string | null | Cuándo se revocó. |
renewal | object | null | opens_at = expires_at menos 30 días; open = true si ya se puede renovar. null mientras no esté emitido. |
identity_url | string | null | Sólo en awaiting_identity: la página donde el titular verifica su identidad. |
payment_url | string | null | Sólo en las respuestas de comprar, renovar y pedir enlace de pago: el enlace para pagar. |
cancel_reason | string | null | Só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). |
subscription | object | null | La 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.id | string | null | El id de la suscripción: sub_…. Es el mismo en todos los certificados que emite. null mientras está pending. |
subscription.status | string | pending (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_file | boolean | true si hay una tarjeta inscrita para el cobro anual. |
subscription.next_charge_at | string | null | Cuá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_amount | integer | null | Cuánto se cobrará, en CLP con IVA incluido. |
subscription.enroll_url | string | null | Dónde inscribir la tarjeta (Webpay Oneclick), mientras no haya una inscrita. |
Estados
| status | Significa | Qué hacer |
|---|---|---|
pending_payment | Creado; falta pagar. Dura 24 horas. | Manda a pagar a payment_url. Si se perdió, pide otro con POST /certificates/{id}/payment-link. |
awaiting_identity | Pagado. 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. |
verifying | La 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_review | Algo del pago o de la emisión lo revisa una persona. | Nada. Te escribimos si hace falta algo. |
active | Emitido y vigente. | Descárgalo con POST /certificates/{id}/download. Renueva desde renewal.opens_at. |
expired | Venció. | Renuévalo (/renew) o compra uno nuevo. |
revoked | Revocado. No firma más. | Compra uno nuevo si el titular lo sigue necesitando. |
canceled | No 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.
- Comprarpending_payment
POST /certificatescrea la compra y devuelvepayment_url. Hay 24 horas para pagar; si no, pasa acanceledy llegacertificate.canceled. - Pagarawaiting_identity
Quien paga abre
payment_urly paga con Webpay. Llegacertificate.paid, conidentity_url. - El titular verifica su identidadactive · verifying
El titular abre
identity_url(o la página dePOST /certificates/{id}/identity-session) y verifica su identidad, en un minuto. No necesita cuenta. Si calza, el certificado se emite y pasa directo aactive; si no, puede intentarlo de nuevo (hasta 3 intentos) y, si aun así no calza, queda enverifyingy lo revisa una persona. Le avisamos por correo, pero conviene que tu sistema también le haga llegar el enlace. - Emitidoactive
Casi siempre, minutos después del pago. Llega
certificate.issued(ocertificate.renewed) y el titular recibe por correo el enlace para descargarlo; tu sistema también puede descargarlo conPOST /certificates/{id}/download. - Por venceractive
30 días antes del vencimiento se abre la renovación (
renewal.open) y llegacertificate.expiring; otra vez a los 7 días. - Renovarpending_payment
POST /certificates/{id}/renewcrea la compra nueva y el ciclo vuelve al paso 2: pagar y verificar identidad otra vez. El certificado anterior sigue vigente hasta su fecha. - Vence o se revocaexpired · revoked
Al vencer llega
certificate.expired. Si la clave se filtró o el titular dejó la empresa,POST /certificates/{id}/revokelo revoca de inmediato y llegacertificate.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.
curl "https://hankos.cl/api/v1/prices" \
-H "Authorization: Bearer $ASTROBIT_API_KEY"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}`);{
"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)
| Nombre | Tipo | Descripción | |
|---|---|---|---|
months | integer | obligatorio | Vigencia 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). |
document | string | opcional | boleta (por omisión) o factura. |
company_rut | string | opcional | RUT de la empresa a la que se factura. Obligatorio con factura; los datos se toman del SII. |
holder.rut | string | obligatorio | RUT personal del titular, con o sin puntos. Una persona natural: un RUT de empresa se rechaza con holder_not_person. |
holder.phone | string | opcional | Celular chileno del titular, en cualquier formato (9 1234 5678, +56 9 1234 5678). Si no es un celular válido, 400 invalid_phone. |
holder.email | string | opcional | Donde le llegan al titular el aviso para verificar su identidad y el certificado. Por omisión, el correo de tu cuenta. |
holder.name | string | opcional | Se 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. |
subscription | boolean | opcional | true 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.
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"
}
}'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}`);{
"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
| Nombre | Tipo | Descripción | |
|---|---|---|---|
status | string | opcional | Sólo los de ese estado. |
holder_rut | string | opcional | Sólo los de ese titular. |
limit | integer | opcional | Cuántos traer, de 1 a 100. Por omisión, 20. |
starting_after | string | opcional | El id del último elemento de la página anterior. |
curl "https://hankos.cl/api/v1/certificates?status=active&limit=20" \
-H "Authorization: Bearer $ASTROBIT_API_KEY"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}`);{
"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
| Nombre | Tipo | Descripción | |
|---|---|---|---|
id | string | obligatorio | El id del certificado (cert-…). |
curl "https://hankos.cl/api/v1/certificates/cert-8f2c1a9b0d3e4f5a6b7c8d9e" \
-H "Authorization: Bearer $ASTROBIT_API_KEY"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}`);{
"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
| Nombre | Tipo | Descripción | |
|---|---|---|---|
id | string | obligatorio | El id del certificado (cert-…). |
En el cuerpo (JSON)
| Nombre | Tipo | Descripción | |
|---|---|---|---|
months | integer | opcional | Por omisión, los del original. |
document | string | opcional | Por omisión, el del original. |
company_rut | string | opcional | Por omisión, el del original si fue con factura. |
Acepta Idempotency-Key.
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
}'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}`);{
"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
| Nombre | Tipo | Descripción | |
|---|---|---|---|
id | string | obligatorio | El id del certificado (cert-…). |
curl -X POST "https://hankos.cl/api/v1/certificates/cert-8f2c1a9b0d3e4f5a6b7c8d9e/payment-link" \
-H "Authorization: Bearer $ASTROBIT_API_KEY"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}`);{
"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
| Nombre | Tipo | Descripción | |
|---|---|---|---|
id | string | obligatorio | El id del certificado (cert-…). |
curl -X POST "https://hankos.cl/api/v1/certificates/cert-8f2c1a9b0d3e4f5a6b7c8d9e/identity-session" \
-H "Authorization: Bearer $ASTROBIT_API_KEY"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}`);{
"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
| Nombre | Tipo | Descripción | |
|---|---|---|---|
id | string | obligatorio | El id del certificado (cert-…). |
En el cuerpo (JSON)
| Nombre | Tipo | Descripción | |
|---|---|---|---|
reason | string | opcional | unspecified (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). |
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"
}'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}`);{
"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
| Nombre | Tipo | Descripción | |
|---|---|---|---|
id | string | obligatorio | El id de cualquier certificado de la suscripción (cert-…). |
curl -X POST "https://hankos.cl/api/v1/certificates/cert-8f2c1a9b0d3e4f5a6b7c8d9e/subscription/cancel" \
-H "Authorization: Bearer $ASTROBIT_API_KEY"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}`);{
"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
| Nombre | Tipo | Descripción | |
|---|---|---|---|
id | string | obligatorio | El id del certificado (cert-…). |
curl -X POST "https://hankos.cl/api/v1/certificates/cert-8f2c1a9b0d3e4f5a6b7c8d9e/download" \
-H "Authorization: Bearer $ASTROBIT_API_KEY"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}`);{
"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
| Nombre | Tipo | Descripción | |
|---|---|---|---|
type | string | opcional | Sólo los de ese tipo. |
limit | integer | opcional | Cuántos traer, de 1 a 100. Por omisión, 20. |
starting_after | string | opcional | El id del último elemento de la página anterior. |
curl "https://hankos.cl/api/v1/events?type=certificate.expiring&limit=20" \
-H "Authorization: Bearer $ASTROBIT_API_KEY"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}`);{
"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
| Nombre | Tipo | Descripción | |
|---|---|---|---|
id | string | obligatorio | El id del evento (evt_…). |
curl "https://hankos.cl/api/v1/events/evt_5c2e8a1f0b9d7c3e6a4f2b18" \
-H "Authorization: Bearer $ASTROBIT_API_KEY"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}`);{
"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.
curl "https://hankos.cl/api/v1/webhooks" \
-H "Authorization: Bearer $ASTROBIT_API_KEY"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}`);{
"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)
| Nombre | Tipo | Descripción | |
|---|---|---|---|
url | string | obligatorio | https, en un host público y en el puerto 443. No se aceptan IP privadas ni localhost. |
events | string[] | obligatorio | Los tipos de evento que quieres recibir, o ["*"] para todos (incluidos los que agreguemos). |
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"
]
}'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}`);{
"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
| Nombre | Tipo | Descripción | |
|---|---|---|---|
id | string | obligatorio | El id del webhook (we_…). |
curl "https://hankos.cl/api/v1/webhooks/we_3a9f0c1d2e4b5a6c7d8e9f01" \
-H "Authorization: Bearer $ASTROBIT_API_KEY"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}`);{
"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
| Nombre | Tipo | Descripción | |
|---|---|---|---|
id | string | obligatorio | El id del webhook (we_…). |
En el cuerpo (JSON)
| Nombre | Tipo | Descripción | |
|---|---|---|---|
url | string | opcional | La URL nueva, con las mismas reglas que al crear. |
events | string[] | opcional | La lista nueva de eventos (reemplaza la anterior). |
enabled | boolean | opcional | false para pausarlo, true para reactivarlo. |
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
}'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}`);{
"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
| Nombre | Tipo | Descripción | |
|---|---|---|---|
id | string | obligatorio | El id del webhook (we_…). |
curl -X DELETE "https://hankos.cl/api/v1/webhooks/we_3a9f0c1d2e4b5a6c7d8e9f01" \
-H "Authorization: Bearer $ASTROBIT_API_KEY"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}`);{
"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
| Nombre | Tipo | Descripción | |
|---|---|---|---|
id | string | obligatorio | El id del webhook (we_…). |
curl -X POST "https://hankos.cl/api/v1/webhooks/we_3a9f0c1d2e4b5a6c7d8e9f01/rotate-secret" \
-H "Authorization: Bearer $ASTROBIT_API_KEY"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}`);{
"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
| Nombre | Tipo | Descripción | |
|---|---|---|---|
id | string | obligatorio | El id del webhook (we_…). |
curl -X POST "https://hankos.cl/api/v1/webhooks/we_3a9f0c1d2e4b5a6c7d8e9f01/test" \
-H "Authorization: Bearer $ASTROBIT_API_KEY"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}`);{
"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
| type | Cuándo | Además en data |
|---|---|---|
certificate.paid | Se confirmó el pago. El titular tiene que verificar su identidad en identity_url. | — |
certificate.issued | Se emitió el certificado de una compra nueva. | — |
certificate.renewed | Se emitió el certificado de una renovación. | previous_certificate_id: el certificado renovado. |
certificate.expiring | Faltan 30 días para el vencimiento, y de nuevo cuando faltan 7. | days_left: los días que faltan. |
certificate.expired | El certificado venció. | — |
certificate.revoked | El certificado fue revocado. | reason: el motivo, como en POST /revoke. |
certificate.canceled | La 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_upcoming | Suscripció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.canceled | Se canceló una suscripción anual (desde la cuenta o la API). | subscription_id. |
ping | La 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
{
"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
{
"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
{
"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
{
"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
{
"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
{
"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
{
"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
{
"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
{
"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
{
"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:
| Cabecera | Valor |
|---|---|
Content-Type | application/json |
User-Agent | Astrobit-Webhooks/1.0 |
Astrobit-Event-Id | El id del evento (evt_…). Úsalo para deduplicar. |
Astrobit-Event-Type | El tipo (certificate.issued, ping…). |
Astrobit-Signature | t=<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:
| Intento | Espera |
|---|---|
| 1 | Ninguna: sale apenas ocurre el evento |
| 2 | 1 minuto |
| 3 | 5 minutos |
| 4 | 30 minutos |
| 5 | 2 horas |
| 6 | 6 horas |
| 7 | 12 horas |
| 8 | 24 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 eliddel 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:
Astrobit-Signature: t=1790517941,v1=5f2b0c…e81a- Separa
t(el momento de la firma, en segundos epoch) yv1(la firma, en hexadecimal). - Arma el texto
<t>.<cuerpo crudo>: el valor det, un punto y el cuerpo exactamente como llegó, byte por byte, antes de parsearlo. - Calcula HMAC-SHA256 de ese texto con tu secreto (el
whsec_…completo, tal cual) y pásalo a hexadecimal. - Compáralo con
v1en tiempo constante. Una comparación común filtra, por lo que tarda, cuánto acertó quien prueba firmas. - Rechaza si
ttiene 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
// 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): 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
# 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 "", 200PHP
<?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_renewableconerror.renewal.opens_at. - Si ya hay una renovación en curso, 409
not_renewableconerror.pending_renewal: paga esa en vez de crear otra. - Si el certificado ya venció, puedes renovarlo mientras
renewal.openseatrue. Si no, compra uno nuevo conPOST /certificates. - El certificado que se renueva sigue vigente hasta su fecha. El nuevo llega con
certificate.renewedydata.previous_certificate_id; el anterior queda conrenewed_by, el id de la renovación más nueva en cualquier estado. Mira esa compra conGET /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:
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
- Primer año. La compra nace en
pending_paymenty se paga enpayment_url, con Webpay, como cualquier otra. - Pagado. Llega
certificate.paid. El titular verifica su identidad enidentity_url, y quien compró inscribe su tarjeta ensubscription.enroll_url. Son independientes: el certificado se emite aunque la tarjeta no esté inscrita. - Emitido. Llega
certificate.issued, ysubscription.next_charge_atqueda 21 días antes del vencimiento, con el monto del año siguiente ensubscription.next_amount. - Aviso. 30 días antes del cobro llega
subscription.renewal_upcoming, concharge_at,amountycard_on_file. Si no hay tarjeta inscrita, es el momento de inscribirla ensubscription.enroll_url: sin tarjeta, la renovación no se cobra ni se emite, y el certificado vence en su fecha. - Cobro. Cuando el cobro queda pagado, se crea la compra de la renovación, ya pagada (
renews= el certificado anterior), y llegacertificate.paidcon suidentity_url: el titular verifica su identidad otra vez, porque Astrobit sólo emite con una verificación de menos de 30 días. - 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:
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 409not_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.