With webhooks, EduPay calls your endpoint when something happens at the school: a student is created, an enrollment is activated, a contract is signed, a charge is issued, an installment is paid or becomes overdue. This keeps your system up to date without polling the API.
Register the endpoint
In the panel: Integrations → Webhooks. Or through the API, with a key that has webhooks:manage:
curl -X POST https://edupay.estudiosite.com/api/v1/webhooks \
-H "Authorization: Bearer $EDUPAY_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://erp.escola.com.br/edupay/avisos",
"description": "ERP financeiro",
"events": ["installment.paid", "installment.overdue"]
}'
- The response contains the secret (
secret, starts withwhsec_) only once. Keep it: it is how you check that a notification came from EduPay. - The endpoint must be
httpsand public. Internal network addresses are rejected. - Up to 10 endpoints per school. In
events, list the events or use["*"]for all of them. - Notifications are only sent while the school's API is turned on. Test sends always work.
Events
| Event | When it happens | Comes in data |
|---|---|---|
student.created |
Student created (panel, sales page, API) | student |
student.updated |
Name, document, contact details, address, status or external code changed | student |
enrollment.created |
Enrollment created (panel, sales page, API) | enrollment, student |
enrollment.activated |
Enrollment became active (contract signed or first payment) | enrollment, student |
enrollment.suspended |
Moodle access blocked for late payment | enrollment, student |
enrollment.reactivated |
Moodle access restored after payment | enrollment, student |
enrollment.cancelled |
Enrollment cancelled | enrollment, student |
enrollment.completed |
Enrollment completed | enrollment, student |
contract.signed |
Student signed, or paper document approved | contract, student |
installment.charge_issued |
Boleto, PIX or card link generated | installment, student |
installment.charge_cancelled |
Charge cancelled (the installment stays open) | installment, student |
installment.paid |
Payment confirmed: bank, registry office or manual payment entry | installment, student |
installment.overdue |
Installment became overdue without payment (daily routine, overnight) | installment, student |
webhook.test |
"Send test" button or POST /webhooks/{id}/test |
test message |
enrollment, contract and installment come complete, in the same format as the API. student has id, external_id, full_name and email (in student events, the full record).
Notification format
POST /edupay/avisos HTTP/1.1
Content-Type: application/json
User-Agent: EduPay-Webhooks/1.0
X-Edupay-Event: installment.paid
X-Edupay-Event-Id: evt_01jbx9m3k2t8f4c6v0w5y7z9qa
X-Edupay-Delivery: 8812
X-Edupay-Signature: t=1793197800,v1=5f2b9c...e81a
{
"id": "evt_01jbx9m3k2t8f4c6v0w5y7z9qa",
"type": "installment.paid",
"created_at": "2026-11-10T13:10:00Z",
"api_version": "v1",
"data": {
"installment": { "id": 9813, "status": "paid", "amount": 45000, "paid_amount": 45000, "currency": "BRL", "...": "..." },
"student": { "id": 532, "external_id": "ALU-0091", "full_name": "Maria Souza", "email": "maria@exemplo.com" }
}
}
Verify the signature
The X-Edupay-Signature header has t (time, in seconds) and v1: the HMAC-SHA256, with the secret, of t + . + the body exactly as it arrived. Check it before anything else and reject notifications older than 5 minutes.
PHP:
$secret = getenv('EDUPAY_WEBHOOK_SECRET');
$body = file_get_contents('php://input'); // the raw body, not decoded
parse_str(str_replace(',', '&', $_SERVER['HTTP_X_EDUPAY_SIGNATURE'] ?? ''), $sig);
$expected = hash_hmac('sha256', ($sig['t'] ?? '') . '.' . $body, $secret);
if (! hash_equals($expected, $sig['v1'] ?? '') || abs(time() - (int) ($sig['t'] ?? 0)) > 300) {
http_response_code(400);
exit;
}
$event = json_decode($body, true);
// ... store $event['id'] and process ...
http_response_code(200);
Node.js (Express):
const crypto = require('crypto');
app.post('/edupay/avisos', express.raw({ type: 'application/json' }), (req, res) => {
const sig = Object.fromEntries(
(req.get('X-Edupay-Signature') || '').split(',').map((p) => p.split('='))
);
const expected = crypto
.createHmac('sha256', process.env.EDUPAY_WEBHOOK_SECRET)
.update(`${sig.t}.${req.body}`)
.digest('hex');
const ok = sig.v1 && sig.v1.length === expected.length
&& crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig.v1))
&& Math.abs(Date.now() / 1000 - Number(sig.t)) <= 300;
if (!ok) return res.sendStatus(400);
const event = JSON.parse(req.body);
// ... store event.id and process ...
res.sendStatus(200);
});
Respond and process
- Respond 2xx within 10 seconds. A redirect counts as a failure. If processing takes long, store the notification, respond 200 and process it later.
- The same notification may arrive more than once (retry, resend from the panel). Use the event
idso you don't process it twice. - Arrival order is not guaranteed. When in doubt, fetch the record from the API (
GET /installments/{id}) to get its current state.
Retries
If your endpoint does not respond 2xx, EduPay tries again after 1 min, 5 min, 30 min, 2 h, 12 h and 24 h. After that, the delivery is marked as failed and can be resent from the panel.
An endpoint that keeps failing for 3 days, with no success, is turned off, and the school's account owner gets an e-mail. It is turned back on in the panel.
The delivery history (status, response and attempts) is kept for 30 days in the panel and in GET /webhooks/{id}/deliveries.
Rotate the secret
In the panel, Rotate secret generates a new secret, which takes effect immediately. Update your system right after.